diff --git a/.agent/workflows/project-settings.md b/.agent/workflows/project-settings.md index 80dd6d9..73fc9ad 100644 --- a/.agent/workflows/project-settings.md +++ b/.agent/workflows/project-settings.md @@ -1,14 +1,16 @@ --- -description: Project-specific settings and preferences for Studio13-v3 +description: Project-specific settings and preferences for OpenStudio --- # Project Settings ## Build Verification -- **Do NOT auto-run build verification commands** after making code changes -- **Do NOT proactively check if builds work** - the user will inform you if something is broken -- Only run build commands when explicitly requested by the user +- Verify every code change in proportion to its risk before handing it off. +- Frontend changes require targeted tests plus `npm run build`; native changes + require the relevant CMake Debug build and deterministic regression harnesses. +- Never treat a successful incremental build as proof that a clean dependency + install or clean CI build works. ## Error Handling @@ -88,16 +90,17 @@ All UI components are located in `frontend/src/components/ui/` and should be use ### Component Reference Files -- [Button.tsx](frontend/src/components/ui/Button/Button.tsx) - Full JSDoc with examples -- [Input.tsx](frontend/src/components/ui/Input/Input.tsx) -- [Select.tsx](frontend/src/components/ui/Select/Select.tsx) -- [Checkbox.tsx](frontend/src/components/ui/Checkbox/Checkbox.tsx) -- [Textarea.tsx](frontend/src/components/ui/Textarea/Textarea.tsx) -- [Slider.tsx](frontend/src/components/ui/Slider/Slider.tsx) -- [Modal.tsx](frontend/src/components/ui/Modal/Modal.tsx) +- [Button.tsx](../../frontend/src/components/ui/Button/Button.tsx) - Full JSDoc with examples +- [Input.tsx](../../frontend/src/components/ui/Input/Input.tsx) +- [Select.tsx](../../frontend/src/components/ui/Select/Select.tsx) +- [Checkbox.tsx](../../frontend/src/components/ui/Checkbox/Checkbox.tsx) +- [Textarea.tsx](../../frontend/src/components/ui/Textarea/Textarea.tsx) +- [Slider.tsx](../../frontend/src/components/ui/Slider/Slider.tsx) +- [Modal.tsx](../../frontend/src/components/ui/Modal/Modal.tsx) ## General Workflow - Make the requested changes directly -- Trust the user to test and report any issues +- Run the automated checks that cover the changed behavior before asking the + user to perform subjective or hardware-dependent testing - Keep explanations concise unless the user asks for details diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 18c09b8..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(npx tsc:*)", - "Bash(npm run build:*)", - "Bash(cmake --build:*)", - "Bash(ls:*)", - "Bash(npm run dev:*)", - "Bash(npm list:*)", - "Bash(where:*)", - "Bash(npx:*)", - "Bash(node -e:*)", - "Bash(npm install:*)", - "Bash(node:*)", - "Bash(dir:*)", - "Bash(python -c:*)", - "Bash(python:*)", - "Bash(# Check if there are leftover Studio13 processes or WebView2 processes tasklist)", - "Bash(echo:*)", - "Read(//c/Users/srvds/AppData/Local/Temp/**)", - "Read(//c/Users/srvds/Documents/Studio13/**)", - "Read(//c/Users/srvds/Documents/**)", - "Read(//c/Users/srvds/OneDrive/Documents/Studio13/**)", - "Read(//c/Users/srvds/**)", - "Bash(find . -name \"*.tsx\" -type f -exec grep -l \"useSortable\\\\|@dnd-kit\" {} ;)", - "WebFetch(domain:forums.steinberg.net)", - "Bash(export PATH=\"$PATH:/c/Program Files/NASM\")", - "Bash(cmake -B build -DCMAKE_BUILD_TYPE=Debug)", - "WebSearch", - "Bash(gh release:*)", - "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3/resources -type f -name *model* -o -name *.onnx -o -name *.ckpt)", - "Bash(find Source -type f \\\\\\( -name \"*.cpp\" -o -name \"*.h\" \\\\\\) -exec wc -l {} +)", - "Bash(find \"c:/Users/srvds/Documents/Codes/Studio13-v3/frontend/src\" -type f \\\\\\( -name \"*.tsx\" -o -name \"*.ts\" -o -name \"*.css\" \\\\\\) -exec wc -l {} +)", - "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3/Source -type f \\\\\\( -name \"*.cpp\" -o -name \"*.h\" \\\\\\) -exec wc -l {} +)", - "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3/frontend/src -type f \\\\\\( -name \"*.ts\" -o -name \"*.tsx\" \\\\\\) -exec wc -l {} +)", - "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3 -type f \\\\\\(-name *test* -o -name *spec* -o -name .github -o -name .gitlab-ci* -o -name azure-pipelines* \\\\\\))", - "Bash(du -sh /c/Users/srvds/Documents/Codes/Studio13-v3/*)", - "Bash(xargs grep:*)", - "Bash(for file:*)", - "Bash(do echo:*)", - "Read(//c/Users/srvds/Documents/Codes/Studio13-v3/**)", - "Bash(done)", - "Bash(wc -l Source/*.cpp Source/*.h)", - "Bash(grep -o \"include.*signalsmith\\\\|include.*ysfx\\\\|include.*lua\\\\|include.*onnx\\\\|include.*clap\" Source/*.h)", - "Bash(grep -r \"^[[:space:]]*\\\\/\\\\/\" Source/*.cpp)", - "Bash(grep -h \"^[[:space:]]*\\\\/\\\\*\" Source/*.cpp)", - "Bash(find /c/Users/srvds/Documents/Codes/Studio13-v3 -type d -iname *test* -o -type d -iname *spec*)" - ], - "additionalDirectories": [ - "C:\\Users\\srvds\\OneDrive\\Documents\\Studio13" - ] - } -} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e07bbe2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Unified-diff context lines contain one required leading space. Treat those +# syntax markers as valid instead of reporting them as trailing whitespace. +*.patch whitespace=-blank-at-eol +*.onnx binary +thirdparty/basic-pitch/LICENSE text eol=lf +thirdparty/basic-pitch/NOTICE text eol=lf +thirdparty/ffmpeg/COPYING.GPLv3 text eol=lf +thirdparty/ffmpeg/PROVENANCE.json text eol=lf +thirdparty/signalsmith/LICENSE.txt text eol=lf +thirdparty/signalsmith/signalsmith-linear/LICENSE.txt text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..077387b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default ownership for every path in OpenStudio. +* @sdevil7th diff --git a/.github/ISSUE_TEMPLATE/window-or-startup-failure.yml b/.github/ISSUE_TEMPLATE/window-or-startup-failure.yml new file mode 100644 index 0000000..57ed42b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/window-or-startup-failure.yml @@ -0,0 +1,115 @@ +name: Window or embedded UI failure +description: Report startup, Mixer, MIDI editor, or built-in plug-in window failures. +title: "[Window/UI]: " +labels: [] +body: + - type: markdown + attributes: + value: | + Thanks for helping test OpenStudio on real hardware. The OS/build details and startup log below let us distinguish a missing runtime from a WebView/WKWebView lifecycle failure. + + - type: input + id: version + attributes: + label: OpenStudio version + description: Use the release/tag shown in the app or the exact commit hash for a development build. + placeholder: v0.0.40 or commit abc1234 + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Operating system + options: + - Windows 10 + - Windows 11 + - macOS (Apple Silicon) + - macOS (Intel) + - Linux + - Other + validations: + required: true + + - type: input + id: os_version + attributes: + label: Exact OS version and build + description: On Windows, run `winver`. On macOS, use About This Mac. + placeholder: Windows 10 22H2 build 19045.6466; macOS 14.7.6 + validations: + required: true + + - type: dropdown + id: window + attributes: + label: Affected window + options: + - Main application startup + - Detached Mixer + - Detached MIDI editor + - Built-in plug-in editor + - Native VST3/CLAP/AU plug-in editor + - Other + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Include open/close/dock/reopen order, whether this was the first launch, and whether another OpenStudio window was still loading. + placeholder: | + 1. Launch OpenStudio + 2. Open ... + 3. Close ... immediately + 4. Reopen ... + validations: + required: true + + - type: textarea + id: observed + attributes: + label: What happened? + description: Say whether the window was blank, never appeared, froze, crashed, or showed a recovery message. + validations: + required: true + + - type: textarea + id: startup_log + attributes: + label: Startup log + description: Paste or attach `OpenStudio_Startup.log`. On Windows it is under `%APPDATA%\OpenStudio\logs`; on macOS it is under `~/Library/Application Support/OpenStudio/logs`. Remove any private project paths if needed. + render: text + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Additional environment details + description: On Windows include the WebView2 version, SmartScreen/Smart App Control result, and any Controlled Folder Access, antivirus, EDR, AppLocker, or WDAC policy. On macOS include Intel/Apple Silicon, whether the DMG/app was signed and notarized, the exact Gatekeeper message, how launch was approved, whether quarantine was removed, and whether Rosetta is installed. + + - type: dropdown + id: trust_state + attributes: + label: Download and first-launch state + options: + - Signed/notarized or verified-publisher build; no warning + - Warning shown; approved with the operating system UI + - macOS quarantine removed with xattr + - Windows security or organization policy blocked launch + - Local development build; no download quarantine + - Unknown + validations: + required: true + + - type: checkboxes + id: checks + attributes: + label: Confirmation + options: + - label: I tested a current release or included the exact development commit. + required: true + - label: I included the complete startup section of the log, including browser backend and frontend startup state. + required: true diff --git a/.github/workflows/ai-runtime-release.yml b/.github/workflows/ai-runtime-release.yml index c3409b4..c4876c3 100644 --- a/.github/workflows/ai-runtime-release.yml +++ b/.github/workflows/ai-runtime-release.yml @@ -10,6 +10,9 @@ on: description: "AI runtime version, for example 0.0.30" required: true +permissions: + contents: read + jobs: build-windows-runtime-base: runs-on: windows-latest @@ -19,7 +22,7 @@ jobs: AI_RUNTIME_STANDALONE_PYTHON_VERSION: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION || '3.10.20' }} AI_RUNTIME_STANDALONE_FLAVOR: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR || 'install_only' }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Normalize runtime version shell: pwsh @@ -28,7 +31,7 @@ jobs: "VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - name: Cache standalone Python and wheels - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | .cache/ai-runtime @@ -59,7 +62,7 @@ jobs: -ExpectedRuntimeVersion $env:VERSION ` -MaxArtifactSizeBytes 2100000000 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: windows-base-ai-runtime path: | @@ -75,14 +78,14 @@ jobs: AI_RUNTIME_STANDALONE_PYTHON_VERSION: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION || '3.10.20' }} AI_RUNTIME_STANDALONE_FLAVOR: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR || 'install_only' }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Normalize runtime version shell: bash run: echo "VERSION=${VERSION#ai-runtime-v}" | sed 's/^VERSION=v/VERSION=/' >> "$GITHUB_ENV" - name: Cache standalone Python and wheels - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | .cache/ai-runtime @@ -91,7 +94,7 @@ jobs: - name: Cache packaged macOS runtime id: cache-macos-runtime-package - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | dist/ai-runtime/OpenStudio-AI-Runtime-macos-arm64.zip @@ -123,7 +126,7 @@ jobs: -ExpectedRuntimeVersion $env:VERSION ` -MaxArtifactSizeBytes 2100000000 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: macos-arm64-ai-runtime path: | @@ -139,14 +142,14 @@ jobs: AI_RUNTIME_STANDALONE_PYTHON_VERSION: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION || '3.10.20' }} AI_RUNTIME_STANDALONE_FLAVOR: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR || 'install_only' }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Normalize runtime version shell: bash run: echo "VERSION=${VERSION#ai-runtime-v}" | sed 's/^VERSION=v/VERSION=/' >> "$GITHUB_ENV" - name: Cache standalone Python and wheels - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: | .cache/ai-runtime @@ -177,7 +180,7 @@ jobs: -ExpectedRuntimeVersion $env:VERSION ` -MaxArtifactSizeBytes 2100000000 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: linux-cpu-x64-ai-runtime path: | @@ -196,23 +199,23 @@ jobs: env: VERSION: ${{ github.event.inputs.runtime_version || github.ref_name }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Normalize runtime version shell: bash run: echo "VERSION=${VERSION#ai-runtime-v}" | sed 's/^VERSION=v/VERSION=/' >> "$GITHUB_ENV" - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: windows-base-ai-runtime path: dist/ai-runtime - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: macos-arm64-ai-runtime path: dist/ai-runtime - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: linux-cpu-x64-ai-runtime path: dist/ai-runtime @@ -228,14 +231,13 @@ jobs: test -f "dist/ai-runtime/reports/linux-cpu-x64-size-report.json" - name: Publish AI runtime release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('ai-runtime-v{0}', github.event.inputs.runtime_version) }} name: OpenStudio AI Runtime ${{ env.VERSION }} body: | OpenStudio AI base runtimes. Windows accelerator backends are installed on-device from the pinned install plans in the published runtime manifest. fail_on_unmatched_files: true - overwrite_files: true files: | dist/ai-runtime/OpenStudio-AI-Runtime-windows-base-x64.zip dist/ai-runtime/OpenStudio-AI-Runtime-macos-arm64.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c470e8..11f13fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,9 @@ on: required: false default: "packaging/release-notes-template.md" +permissions: + contents: read + jobs: build-windows: runs-on: windows-latest @@ -22,35 +25,16 @@ jobs: BUILD_DIR: build-release-windows ASIO_SDK_DIR: thirdparty/asio ONNXRUNTIME_VERSION: 1.24.4 + ONNXRUNTIME_WIN_X64_SHA256: d2319fddfb6ea4db99ccc4b60c85c517bcd855721f5daa6a06d40d7cb2ee2357 + FFMPEG_CORRESPONDING_SOURCE_URL: ${{ vars.OPENSTUDIO_FFMPEG_CORRESPONDING_SOURCE_URL }} + FFMPEG_CORRESPONDING_SOURCE_SHA256: ${{ vars.OPENSTUDIO_FFMPEG_CORRESPONDING_SOURCE_SHA256 }} AI_RUNTIME_VERSION: ${{ vars.OPENSTUDIO_AI_RUNTIME_VERSION != '' && vars.OPENSTUDIO_AI_RUNTIME_VERSION || github.event.inputs.version || github.ref_name }} AI_RUNTIME_STANDALONE_RELEASE_TAG: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_RELEASE_TAG != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_RELEASE_TAG || '20260325' }} AI_RUNTIME_STANDALONE_PYTHON_VERSION: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION || '3.10.20' }} AI_RUNTIME_STANDALONE_FLAVOR: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR || 'install_only' }} RELEASE_SITE_URL: https://openstudio.org.in - DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} - WINDOWS_CODESIGN_CERT_BASE64: ${{ secrets.WINDOWS_CODESIGN_CERT_BASE64 }} - WINDOWS_CODESIGN_CERT_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_CERT_PASSWORD }} - WINDOWS_CODESIGN_CERT_THUMBPRINT: ${{ secrets.WINDOWS_CODESIGN_CERT_THUMBPRINT }} - WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }} steps: - - uses: actions/checkout@v5 - - - name: Install Doppler CLI - if: env.DOPPLER_TOKEN != '' - uses: dopplerhq/cli-action@v3 - - - name: Load release secrets from Doppler - if: env.DOPPLER_TOKEN != '' - shell: pwsh - run: | - $secrets = doppler secrets download --no-file --format json | ConvertFrom-Json -AsHashtable - foreach ($entry in $secrets.GetEnumerator()) { - if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($entry.Key))) { - continue - } - - "$($entry.Key)=$($entry.Value)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - } + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Normalize version shell: pwsh @@ -60,9 +44,9 @@ jobs: $aiRuntimeVersion = "${env:AI_RUNTIME_VERSION}" -replace '^v', '' "AI_RUNTIME_VERSION=$aiRuntimeVersion" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 20 + node-version-file: frontend/.nvmrc cache: npm cache-dependency-path: frontend/package-lock.json @@ -71,7 +55,9 @@ jobs: run: | cd frontend npm ci + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } npm run build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Install Inno Setup shell: pwsh @@ -85,20 +71,67 @@ jobs: shell: pwsh run: ./tools/setup-asio-sdk.ps1 -Destination $env:ASIO_SDK_DIR - - name: Install optional ONNX Runtime - if: vars.OPENSTUDIO_SETUP_ONNXRUNTIME == 'true' + - name: Install ONNX Runtime + shell: pwsh + run: ./tools/setup-onnxruntime.ps1 -Version $env:ONNXRUNTIME_VERSION -ExpectedSha256 $env:ONNXRUNTIME_WIN_X64_SHA256 + + - name: Detect Doppler fallback shell: pwsh - run: ./tools/setup-onnxruntime.ps1 -Version $env:ONNXRUNTIME_VERSION + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + run: | + $configured = if ([string]::IsNullOrWhiteSpace($env:DOPPLER_TOKEN)) { "false" } else { "true" } + "DOPPLER_CONFIGURED=$configured" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Install Doppler CLI + if: env.DOPPLER_CONFIGURED == 'true' + uses: dopplerhq/cli-action@014df23b1329b615816a38eb5f473bb9000700b1 # v3 - name: Configure CMake shell: pwsh + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + TONE3000_PUBLISHABLE_KEY: ${{ vars.TONE3000_PUBLISHABLE_KEY != '' && vars.TONE3000_PUBLISHABLE_KEY || secrets.TONE3000_PUBLISHABLE_KEY }} + OPENSTUDIO_TONE3000_CLIENT_ID_VALUE: ${{ secrets.OPENSTUDIO_TONE3000_CLIENT_ID_VALUE }} + OPENSTUDIO_TONE3000_CLIENT_ID: ${{ secrets.OPENSTUDIO_TONE3000_CLIENT_ID }} run: | + function Get-AllowlistedDopplerValue { + param([Parameter(Mandatory = $true)][string]$Name) + if ([string]::IsNullOrWhiteSpace($env:DOPPLER_TOKEN)) { return "" } + $value = (& doppler secrets get $Name --plain 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { return "" } + if (-not [string]::IsNullOrWhiteSpace($value)) { Write-Host "::add-mask::$value" } + return $value + } + + $tone3000ClientId = $env:TONE3000_PUBLISHABLE_KEY + if ([string]::IsNullOrWhiteSpace($tone3000ClientId)) { + $tone3000ClientId = $env:OPENSTUDIO_TONE3000_CLIENT_ID_VALUE + } + if ([string]::IsNullOrWhiteSpace($tone3000ClientId)) { + $tone3000ClientId = $env:OPENSTUDIO_TONE3000_CLIENT_ID + } + if ([string]::IsNullOrWhiteSpace($tone3000ClientId)) { + foreach ($name in @("TONE3000_PUBLISHABLE_KEY", "OPENSTUDIO_TONE3000_CLIENT_ID_VALUE", "OPENSTUDIO_TONE3000_CLIENT_ID")) { + $tone3000ClientId = Get-AllowlistedDopplerValue -Name $name + if (-not [string]::IsNullOrWhiteSpace($tone3000ClientId)) { break } + } + } + if ([string]::IsNullOrWhiteSpace($tone3000ClientId)) { + throw "A TONE3000 publishable OAuth client ID is required for release builds." + } + $env:DOPPLER_TOKEN = "" + $env:TONE3000_PUBLISHABLE_KEY = "" + $env:OPENSTUDIO_TONE3000_CLIENT_ID_VALUE = "" + $env:OPENSTUDIO_TONE3000_CLIENT_ID = "" + cmake -S . -B $env:BUILD_DIR -A x64 ` "-DOPENSTUDIO_APP_VERSION=$env:VERSION" ` "-DOPENSTUDIO_UPDATE_MANIFEST_URL_VALUE=$env:RELEASE_SITE_URL/releases/stable/latest.json" ` "-DOPENSTUDIO_UPDATE_APPCAST_URL_VALUE=$env:RELEASE_SITE_URL/appcast/windows-stable.xml" ` "-DOPENSTUDIO_RELEASES_PAGE_URL_VALUE=$env:RELEASE_SITE_URL/download" ` "-DOPENSTUDIO_UPDATE_CHANNEL_VALUE=stable" ` + "-DOPENSTUDIO_TONE3000_CLIENT_ID_VALUE=$tone3000ClientId" ` "-DJUCE_ASIOSDK_PATH=$env:GITHUB_WORKSPACE/$env:ASIO_SDK_DIR" ` "-DOPENSTUDIO_REQUIRE_ASIO=ON" ` "-DOPENSTUDIO_ENABLE_EXTERNAL_PYTHON_AI_FALLBACK=OFF" ` @@ -112,21 +145,61 @@ jobs: shell: pwsh run: ./tools/validate-runtime-bundle.ps1 -Platform windows -BundlePath "$env:BUILD_DIR/OpenStudio_artefacts/Release" -ExpectedVersion $env:VERSION -EnforceLeanBundle + - name: Run Windows native-window lifecycle release gate + shell: pwsh + run: | + $exePath = Join-Path $env:GITHUB_WORKSPACE "$env:BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.exe" + $report = Join-Path $env:RUNNER_TEMP "OpenStudio_WindowLifecycleHarness.json" + ./tools/run-window-lifecycle-smoke.ps1 -AppPath $exePath -ReportPath $report -TimeoutSeconds 180 + - name: Prepare Windows signing certificate shell: pwsh + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + WINDOWS_CODESIGN_CERT_BASE64: ${{ secrets.WINDOWS_CODESIGN_CERT_BASE64 }} run: | - if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_CERT_BASE64)) { + $certificateBase64 = $env:WINDOWS_CODESIGN_CERT_BASE64 + if ([string]::IsNullOrWhiteSpace($certificateBase64) -and -not [string]::IsNullOrWhiteSpace($env:DOPPLER_TOKEN)) { + $certificateBase64 = (& doppler secrets get WINDOWS_CODESIGN_CERT_BASE64 --plain 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { $certificateBase64 = "" } + } + $env:DOPPLER_TOKEN = "" + $env:WINDOWS_CODESIGN_CERT_BASE64 = "" + if ([string]::IsNullOrWhiteSpace($certificateBase64)) { Write-Host "No Windows code-signing certificate secret was provided." exit 0 } + Write-Host "::add-mask::$certificateBase64" $certPath = Join-Path $env:RUNNER_TEMP "openstudio-codesign.pfx" - [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String($env:WINDOWS_CODESIGN_CERT_BASE64)) + [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String($certificateBase64)) "WINDOWS_CODESIGN_CERT_PATH=$certPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - name: Package Windows installer shell: pwsh + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + WINDOWS_CODESIGN_CERT_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_CERT_PASSWORD }} + WINDOWS_CODESIGN_CERT_THUMBPRINT: ${{ secrets.WINDOWS_CODESIGN_CERT_THUMBPRINT }} + WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }} run: | + function Get-ReleaseValue { + param([Parameter(Mandatory = $true)][string]$Name, [string]$DirectValue) + if (-not [string]::IsNullOrWhiteSpace($DirectValue)) { return $DirectValue } + if ([string]::IsNullOrWhiteSpace($env:DOPPLER_TOKEN)) { return "" } + $value = (& doppler secrets get $Name --plain 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { return "" } + if (-not [string]::IsNullOrWhiteSpace($value)) { Write-Host "::add-mask::$value" } + return $value + } + + $certificatePassword = Get-ReleaseValue -Name "WINDOWS_CODESIGN_CERT_PASSWORD" -DirectValue $env:WINDOWS_CODESIGN_CERT_PASSWORD + $certificateThumbprint = Get-ReleaseValue -Name "WINDOWS_CODESIGN_CERT_THUMBPRINT" -DirectValue $env:WINDOWS_CODESIGN_CERT_THUMBPRINT + $timestampUrl = Get-ReleaseValue -Name "WINDOWS_TIMESTAMP_URL" -DirectValue $env:WINDOWS_TIMESTAMP_URL + $env:DOPPLER_TOKEN = "" + $env:WINDOWS_CODESIGN_CERT_PASSWORD = "" + $env:WINDOWS_CODESIGN_CERT_THUMBPRINT = "" + $env:WINDOWS_TIMESTAMP_URL = "" $arguments = @{ Version = $env:VERSION SourceDir = "$env:BUILD_DIR/OpenStudio_artefacts/Release" @@ -136,16 +209,16 @@ jobs: $arguments.CertificateFile = $env:WINDOWS_CODESIGN_CERT_PATH } - if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_CERT_PASSWORD)) { - $arguments.CertificatePassword = $env:WINDOWS_CODESIGN_CERT_PASSWORD + if (-not [string]::IsNullOrWhiteSpace($certificatePassword)) { + $arguments.CertificatePassword = $certificatePassword } - if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_CERT_THUMBPRINT)) { - $arguments.CertificateThumbprint = $env:WINDOWS_CODESIGN_CERT_THUMBPRINT + if (-not [string]::IsNullOrWhiteSpace($certificateThumbprint)) { + $arguments.CertificateThumbprint = $certificateThumbprint } - if (-not [string]::IsNullOrWhiteSpace($env:WINDOWS_TIMESTAMP_URL)) { - $arguments.TimestampUrl = $env:WINDOWS_TIMESTAMP_URL + if (-not [string]::IsNullOrWhiteSpace($timestampUrl)) { + $arguments.TimestampUrl = $timestampUrl } ./tools/package-windows-release.ps1 @arguments @@ -164,10 +237,34 @@ jobs: } } - - uses: actions/upload-artifact@v4 + - name: Stage checksum-pinned FFmpeg corresponding source + shell: pwsh + run: | + $sourceUrl = ([string]$env:FFMPEG_CORRESPONDING_SOURCE_URL).Trim() + $expectedSha256 = ([string]$env:FFMPEG_CORRESPONDING_SOURCE_SHA256).Trim().ToLowerInvariant() + if ([string]::IsNullOrWhiteSpace($sourceUrl) -or + $expectedSha256 -notmatch '^[0-9a-f]{64}$') { + throw "Set OPENSTUDIO_FFMPEG_CORRESPONDING_SOURCE_URL and OPENSTUDIO_FFMPEG_CORRESPONDING_SOURCE_SHA256 to the complete corresponding-source archive for the exact bundled FFmpeg 8.0.1 static build." + } + $sourceUri = [Uri]$sourceUrl + if (-not $sourceUri.IsAbsoluteUri -or $sourceUri.Scheme -ne "https") { + throw "OPENSTUDIO_FFMPEG_CORRESPONDING_SOURCE_URL must be an absolute HTTPS URL." + } + + $sourcePath = "dist/windows/OpenStudio-FFmpeg-8.0.1-complete-corresponding-source.zip" + Invoke-WebRequest -Uri $sourceUri -OutFile $sourcePath -UseBasicParsing + $actualSha256 = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualSha256 -ne $expectedSha256) { + Remove-Item -LiteralPath $sourcePath -Force + throw "FFmpeg corresponding-source checksum mismatch. Expected '$expectedSha256' but found '$actualSha256'." + } + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: windows-release - path: dist/windows/OpenStudio-Setup-x64.exe + path: | + dist/windows/OpenStudio-Setup-x64.exe + dist/windows/OpenStudio-FFmpeg-8.0.1-complete-corresponding-source.zip if-no-files-found: error build-macos: @@ -180,33 +277,8 @@ jobs: AI_RUNTIME_STANDALONE_PYTHON_VERSION: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_PYTHON_VERSION || '3.10.20' }} AI_RUNTIME_STANDALONE_FLAVOR: ${{ vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR != '' && vars.OPENSTUDIO_AI_RUNTIME_STANDALONE_FLAVOR || 'install_only' }} RELEASE_SITE_URL: https://openstudio.org.in - DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} - MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} - MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} steps: - - uses: actions/checkout@v5 - - - name: Install Doppler CLI - if: env.DOPPLER_TOKEN != '' - uses: dopplerhq/cli-action@v3 - - - name: Load release secrets from Doppler - if: env.DOPPLER_TOKEN != '' - shell: pwsh - run: | - $secrets = doppler secrets download --no-file --format json | ConvertFrom-Json -AsHashtable - foreach ($entry in $secrets.GetEnumerator()) { - if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($entry.Key))) { - continue - } - - "$($entry.Key)=$($entry.Value)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - } + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Normalize version shell: bash @@ -214,9 +286,9 @@ jobs: echo "VERSION=${VERSION#v}" >> "$GITHUB_ENV" echo "AI_RUNTIME_VERSION=${AI_RUNTIME_VERSION#v}" >> "$GITHUB_ENV" - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 20 + node-version-file: frontend/.nvmrc cache: npm cache-dependency-path: frontend/package-lock.json @@ -227,32 +299,103 @@ jobs: npm ci npm run build + - name: Detect Doppler fallback + shell: bash + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + run: | + if [[ -n "${DOPPLER_TOKEN:-}" ]]; then + echo "DOPPLER_CONFIGURED=true" >> "$GITHUB_ENV" + else + echo "DOPPLER_CONFIGURED=false" >> "$GITHUB_ENV" + fi + + - name: Install Doppler CLI + if: env.DOPPLER_CONFIGURED == 'true' + uses: dopplerhq/cli-action@014df23b1329b615816a38eb5f473bb9000700b1 # v3 + - name: Prepare macOS signing certificate - if: env.MACOS_CERTIFICATE_BASE64 != '' shell: bash + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} run: | + resolve_release_value() { + local name="$1" + local direct_value="${2:-}" + if [[ -n "$direct_value" ]]; then printf '%s' "$direct_value"; return 0; fi + if [[ -z "${DOPPLER_TOKEN:-}" ]]; then return 0; fi + doppler secrets get "$name" --plain 2>/dev/null || true + } + + CERTIFICATE_BASE64="$(resolve_release_value MACOS_CERTIFICATE_BASE64 "${MACOS_CERTIFICATE_BASE64:-}")" + CERTIFICATE_PASSWORD="$(resolve_release_value MACOS_CERTIFICATE_PASSWORD "${MACOS_CERTIFICATE_PASSWORD:-}")" + KEYCHAIN_PASSWORD="$(resolve_release_value MACOS_KEYCHAIN_PASSWORD "${MACOS_KEYCHAIN_PASSWORD:-}")" + unset DOPPLER_TOKEN MACOS_CERTIFICATE_BASE64 MACOS_CERTIFICATE_PASSWORD MACOS_KEYCHAIN_PASSWORD + if [[ -z "$CERTIFICATE_BASE64" ]]; then + echo "No macOS code-signing certificate was provided." + exit 0 + fi + if [[ -z "$CERTIFICATE_PASSWORD" || -z "$KEYCHAIN_PASSWORD" ]]; then + echo "A macOS certificate requires both certificate and keychain passwords." >&2 + exit 1 + fi + echo "::add-mask::$CERTIFICATE_BASE64" + echo "::add-mask::$CERTIFICATE_PASSWORD" + echo "::add-mask::$KEYCHAIN_PASSWORD" + CERT_PATH="$RUNNER_TEMP/openstudio-macos-signing.p12" KEYCHAIN_PATH="$RUNNER_TEMP/openstudio-signing.keychain-db" EXISTING_KEYCHAINS="$(security list-keychains -d user | sed 's/^[[:space:]]*//; s/^\"//; s/\"$//')" - echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH" - security create-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + echo "$CERTIFICATE_BASE64" | base64 --decode > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcrun - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcrun + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security list-keychains -d user -s "$KEYCHAIN_PATH" $EXISTING_KEYCHAINS echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" - name: Configure CMake shell: bash + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + TONE3000_PUBLISHABLE_KEY: ${{ vars.TONE3000_PUBLISHABLE_KEY != '' && vars.TONE3000_PUBLISHABLE_KEY || secrets.TONE3000_PUBLISHABLE_KEY }} + OPENSTUDIO_TONE3000_CLIENT_ID_VALUE: ${{ secrets.OPENSTUDIO_TONE3000_CLIENT_ID_VALUE }} + OPENSTUDIO_TONE3000_CLIENT_ID: ${{ secrets.OPENSTUDIO_TONE3000_CLIENT_ID }} run: | + resolve_doppler_value() { + local name="$1" + if [[ -z "${DOPPLER_TOKEN:-}" ]]; then return 0; fi + doppler secrets get "$name" --plain 2>/dev/null || true + } + + TONE3000_CLIENT_ID="${TONE3000_PUBLISHABLE_KEY:-${OPENSTUDIO_TONE3000_CLIENT_ID_VALUE:-${OPENSTUDIO_TONE3000_CLIENT_ID:-}}}" + if [[ -z "$TONE3000_CLIENT_ID" ]]; then + for name in TONE3000_PUBLISHABLE_KEY OPENSTUDIO_TONE3000_CLIENT_ID_VALUE OPENSTUDIO_TONE3000_CLIENT_ID; do + TONE3000_CLIENT_ID="$(resolve_doppler_value "$name")" + if [[ -n "$TONE3000_CLIENT_ID" ]]; then + echo "::add-mask::$TONE3000_CLIENT_ID" + break + fi + done + fi + if [ -z "$TONE3000_CLIENT_ID" ]; then + echo "A TONE3000 publishable OAuth client ID is required for release builds." >&2 + exit 1 + fi + unset DOPPLER_TOKEN TONE3000_PUBLISHABLE_KEY OPENSTUDIO_TONE3000_CLIENT_ID_VALUE OPENSTUDIO_TONE3000_CLIENT_ID + cmake -S . -B "$BUILD_DIR" \ -DOPENSTUDIO_APP_VERSION="$VERSION" \ -DOPENSTUDIO_UPDATE_MANIFEST_URL_VALUE="$RELEASE_SITE_URL/releases/stable/latest.json" \ -DOPENSTUDIO_UPDATE_APPCAST_URL_VALUE="$RELEASE_SITE_URL/appcast/macos-stable.xml" \ -DOPENSTUDIO_RELEASES_PAGE_URL_VALUE="$RELEASE_SITE_URL/download" \ -DOPENSTUDIO_UPDATE_CHANNEL_VALUE="stable" \ + -DOPENSTUDIO_TONE3000_CLIENT_ID_VALUE="$TONE3000_CLIENT_ID" \ -DOPENSTUDIO_ENABLE_EXTERNAL_PYTHON_AI_FALLBACK=OFF \ -DFETCHCONTENT_UPDATES_DISCONNECTED=ON @@ -270,9 +413,41 @@ jobs: ./tools/validate-runtime-bundle.ps1 -Platform macos -BundlePath $appPath.FullName -ExpectedVersion $env:VERSION -EnforceLeanBundle + - name: Validate universal macOS executable + shell: bash + run: | + APP_PATH="$(find "$BUILD_DIR" -type d -name 'OpenStudio.app' | head -n 1)" + ARCHS="$(lipo -archs "$APP_PATH/Contents/MacOS/OpenStudio")" + echo "OpenStudio architectures: $ARCHS" + [[ " $ARCHS " == *" arm64 "* ]] + [[ " $ARCHS " == *" x86_64 "* ]] + - name: Package macOS DMG shell: bash + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} run: | + resolve_release_value() { + local name="$1" + local direct_value="${2:-}" + if [[ -n "$direct_value" ]]; then printf '%s' "$direct_value"; return 0; fi + if [[ -z "${DOPPLER_TOKEN:-}" ]]; then return 0; fi + doppler secrets get "$name" --plain 2>/dev/null || true + } + + export MACOS_CODESIGN_IDENTITY="$(resolve_release_value MACOS_CODESIGN_IDENTITY "${MACOS_CODESIGN_IDENTITY:-}")" + export APPLE_ID="$(resolve_release_value APPLE_ID "${APPLE_ID:-}")" + export APPLE_TEAM_ID="$(resolve_release_value APPLE_TEAM_ID "${APPLE_TEAM_ID:-}")" + export APPLE_APP_PASSWORD="$(resolve_release_value APPLE_APP_PASSWORD "${APPLE_APP_PASSWORD:-}")" + for value in "$MACOS_CODESIGN_IDENTITY" "$APPLE_ID" "$APPLE_TEAM_ID" "$APPLE_APP_PASSWORD"; do + if [[ -n "$value" ]]; then echo "::add-mask::$value"; fi + done + unset DOPPLER_TOKEN + chmod +x ./tools/package-macos-release.sh APP_PATH="$(find "$BUILD_DIR" -type d -name 'OpenStudio.app' | head -n 1)" if [[ -z "$APP_PATH" ]]; then @@ -280,6 +455,40 @@ jobs: exit 1 fi ./tools/package-macos-release.sh "$APP_PATH" "$VERSION" + if [[ -n "$MACOS_CODESIGN_IDENTITY" ]]; then + echo "OPENSTUDIO_MACOS_PACKAGE_SIGNED=true" >> "$GITHUB_ENV" + fi + if [[ -n "$APPLE_ID" && -n "$APPLE_TEAM_ID" && -n "$APPLE_APP_PASSWORD" ]]; then + echo "OPENSTUDIO_MACOS_PACKAGE_NOTARIZED=true" >> "$GITHUB_ENV" + fi + + - name: Run packaged macOS native-window lifecycle release gate + shell: bash + run: | + MOUNT_DIR="$(mktemp -d)" + REPORT_PATH="$RUNNER_TEMP/OpenStudio_PackagedWindowLifecycleHarness.json" + cleanup() { + hdiutil detach "$MOUNT_DIR" -quiet || true + rmdir "$MOUNT_DIR" 2>/dev/null || true + } + trap cleanup EXIT + + hdiutil attach "dist/macos/OpenStudio-macOS.dmg" -nobrowse -readonly -mountpoint "$MOUNT_DIR" + APP_PATH="$MOUNT_DIR/OpenStudio.app" + test -x "$APP_PATH/Contents/MacOS/OpenStudio" + + if [[ "${OPENSTUDIO_MACOS_PACKAGE_SIGNED:-false}" == "true" ]]; then + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + fi + + if [[ "${OPENSTUDIO_MACOS_PACKAGE_NOTARIZED:-false}" == "true" ]]; then + spctl --assess --type execute --verbose=2 "$APP_PATH" + fi + + pwsh -NoProfile -File ./tools/run-window-lifecycle-smoke.ps1 \ + -AppPath "$APP_PATH/Contents/MacOS/OpenStudio" \ + -ReportPath "$REPORT_PATH" \ + -TimeoutSeconds 180 - name: Verify macOS release outputs shell: bash @@ -291,7 +500,7 @@ jobs: shell: bash run: security delete-keychain "$KEYCHAIN_PATH" || true - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: macos-release path: dist/macos/OpenStudio-macOS.dmg @@ -303,9 +512,10 @@ jobs: VERSION: ${{ github.event.inputs.version || github.ref_name }} BUILD_DIR: build-release-linux ONNXRUNTIME_VERSION: 1.24.4 + ONNXRUNTIME_LINUX_X64_SHA256: 3a211fbea252c1e66290658f1b735b772056149f28321e71c308942cdb54b747 RELEASE_SITE_URL: https://openstudio.org.in steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Normalize version shell: bash @@ -319,13 +529,13 @@ jobs: libasound2-dev libjack-jackd2-dev \ libwebkit2gtk-4.1-dev libgtk-3-dev \ libgl1-mesa-dev libfreetype6-dev libfontconfig1-dev \ - libcurl4-openssl-dev \ + libcurl4-openssl-dev libsecret-tools \ libx11-dev libxext-dev libxrandr-dev libxi-dev \ libxinerama-dev libxcursor-dev libxcomposite-dev - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 20 + node-version-file: frontend/.nvmrc cache: npm cache-dependency-path: frontend/package-lock.json @@ -336,11 +546,54 @@ jobs: run: | URL="https://github.com/microsoft/onnxruntime/releases/download/v${ONNXRUNTIME_VERSION}/onnxruntime-linux-x64-${ONNXRUNTIME_VERSION}.tgz" wget -q "$URL" -O onnxruntime.tgz + echo "${ONNXRUNTIME_LINUX_X64_SHA256} onnxruntime.tgz" | sha256sum --check --strict mkdir -p thirdparty/onnxruntime tar -xzf onnxruntime.tgz --strip-components=1 -C thirdparty/onnxruntime + - name: Detect Doppler fallback + shell: bash + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + run: | + if [[ -n "${DOPPLER_TOKEN:-}" ]]; then + echo "DOPPLER_CONFIGURED=true" >> "$GITHUB_ENV" + else + echo "DOPPLER_CONFIGURED=false" >> "$GITHUB_ENV" + fi + + - name: Install Doppler CLI + if: env.DOPPLER_CONFIGURED == 'true' + uses: dopplerhq/cli-action@014df23b1329b615816a38eb5f473bb9000700b1 # v3 + - name: Configure CMake + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + TONE3000_PUBLISHABLE_KEY: ${{ vars.TONE3000_PUBLISHABLE_KEY != '' && vars.TONE3000_PUBLISHABLE_KEY || secrets.TONE3000_PUBLISHABLE_KEY }} + OPENSTUDIO_TONE3000_CLIENT_ID_VALUE: ${{ secrets.OPENSTUDIO_TONE3000_CLIENT_ID_VALUE }} + OPENSTUDIO_TONE3000_CLIENT_ID: ${{ secrets.OPENSTUDIO_TONE3000_CLIENT_ID }} run: | + resolve_doppler_value() { + local name="$1" + if [[ -z "${DOPPLER_TOKEN:-}" ]]; then return 0; fi + doppler secrets get "$name" --plain 2>/dev/null || true + } + + TONE3000_CLIENT_ID="${TONE3000_PUBLISHABLE_KEY:-${OPENSTUDIO_TONE3000_CLIENT_ID_VALUE:-${OPENSTUDIO_TONE3000_CLIENT_ID:-}}}" + if [[ -z "$TONE3000_CLIENT_ID" ]]; then + for name in TONE3000_PUBLISHABLE_KEY OPENSTUDIO_TONE3000_CLIENT_ID_VALUE OPENSTUDIO_TONE3000_CLIENT_ID; do + TONE3000_CLIENT_ID="$(resolve_doppler_value "$name")" + if [[ -n "$TONE3000_CLIENT_ID" ]]; then + echo "::add-mask::$TONE3000_CLIENT_ID" + break + fi + done + fi + if [ -z "$TONE3000_CLIENT_ID" ]; then + echo "A TONE3000 publishable OAuth client ID is required for release builds." >&2 + exit 1 + fi + unset DOPPLER_TOKEN TONE3000_PUBLISHABLE_KEY OPENSTUDIO_TONE3000_CLIENT_ID_VALUE OPENSTUDIO_TONE3000_CLIENT_ID + cmake -S . -B "$BUILD_DIR" -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DOPENSTUDIO_APP_VERSION="$VERSION" \ @@ -348,12 +601,17 @@ jobs: -DOPENSTUDIO_UPDATE_APPCAST_URL_VALUE="$RELEASE_SITE_URL/appcast/linux-stable.xml" \ -DOPENSTUDIO_RELEASES_PAGE_URL_VALUE="$RELEASE_SITE_URL/download" \ -DOPENSTUDIO_UPDATE_CHANNEL_VALUE="stable" \ + -DOPENSTUDIO_TONE3000_CLIENT_ID_VALUE="$TONE3000_CLIENT_ID" \ -DOPENSTUDIO_ENABLE_EXTERNAL_PYTHON_AI_FALLBACK=OFF \ -DFETCHCONTENT_UPDATES_DISCONNECTED=ON - name: Build OpenStudio run: cmake --build "$BUILD_DIR" --config Release --target OpenStudio + - name: Validate Linux runtime bundle + shell: pwsh + run: ./tools/validate-runtime-bundle.ps1 -Platform linux -BundlePath "$env:BUILD_DIR/OpenStudio_artefacts/Release" -ExpectedVersion $env:VERSION -EnforceLeanBundle + - name: Package AppImage run: | chmod +x ./tools/package-linux-release.sh @@ -362,7 +620,25 @@ jobs: - name: Verify Linux release outputs run: test -f "dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" - - uses: actions/upload-artifact@v4 + - name: Validate packaged Linux AppImage contents + shell: bash + run: | + APPIMAGE="$GITHUB_WORKSPACE/dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" + EXTRACT_ROOT="$(mktemp -d)" + cleanup() { rm -rf -- "$EXTRACT_ROOT"; } + trap cleanup EXIT + chmod +x "$APPIMAGE" + ( + cd "$EXTRACT_ROOT" + "$APPIMAGE" --appimage-extract >/dev/null + ) + pwsh -NoProfile -File "$GITHUB_WORKSPACE/tools/validate-runtime-bundle.ps1" \ + -Platform linux \ + -BundlePath "$EXTRACT_ROOT/squashfs-root/usr/bin" \ + -ExpectedVersion "$VERSION" \ + -EnforceLeanBundle + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: linux-release path: dist/linux/OpenStudio-*-linux-x86_64.AppImage @@ -381,28 +657,10 @@ jobs: RELEASE_NOTES_FILE: ${{ github.event.inputs.release_notes_file || 'packaging/release-notes-template.md' }} AI_RUNTIME_VERSION: ${{ vars.OPENSTUDIO_AI_RUNTIME_VERSION != '' && vars.OPENSTUDIO_AI_RUNTIME_VERSION || github.event.inputs.version || github.ref_name }} AI_RUNTIME_RELEASE_TAG: ${{ vars.OPENSTUDIO_AI_RUNTIME_RELEASE_TAG }} - DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} WEBSITE_REPO: ${{ vars.OPENSTUDIO_WEBSITE_REPO != '' && vars.OPENSTUDIO_WEBSITE_REPO || 'sdevil7th/OpenStudioWebsite' }} WEBSITE_DISPATCH_EVENT_TYPE: ${{ vars.OPENSTUDIO_WEBSITE_DISPATCH_EVENT_TYPE != '' && vars.OPENSTUDIO_WEBSITE_DISPATCH_EVENT_TYPE || 'openstudio_release_published' }} steps: - - uses: actions/checkout@v5 - - - name: Install Doppler CLI - if: env.DOPPLER_TOKEN != '' - uses: dopplerhq/cli-action@v3 - - - name: Load release secrets from Doppler - if: env.DOPPLER_TOKEN != '' - shell: pwsh - run: | - $secrets = doppler secrets download --no-file --format json | ConvertFrom-Json -AsHashtable - foreach ($entry in $secrets.GetEnumerator()) { - if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($entry.Key))) { - continue - } - - "$($entry.Key)=$($entry.Value)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - } + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Normalize version shell: bash @@ -430,17 +688,17 @@ jobs: echo "Either run .github/workflows/ai-runtime-release.yml first, or point OPENSTUDIO_AI_RUNTIME_RELEASE_TAG / OPENSTUDIO_AI_RUNTIME_VERSION at an existing runtime release." >&2 exit 1 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: windows-release path: dist/windows - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: macos-release path: dist/macos - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: linux-release path: dist/linux @@ -469,6 +727,7 @@ jobs: run: | $requiredFiles = @( "dist/windows/OpenStudio-Setup-x64.exe", + "dist/windows/OpenStudio-FFmpeg-8.0.1-complete-corresponding-source.zip", "dist/macos/OpenStudio-macOS.dmg", "dist/ai-runtime/OpenStudio-AI-Runtime-windows-base-x64.zip", "dist/ai-runtime/OpenStudio-AI-Runtime-macos-arm64.zip", @@ -508,6 +767,7 @@ jobs: -WindowsInstallerArguments "/SP- /NOICONS" ` -MacAssetPath "dist/macos/OpenStudio-macOS.dmg" ` -MacAssetUrl $macosUrl ` + -MacMinimumSystemVersion "12.0" ` -LinuxAssetPath "dist/linux/OpenStudio-$env:VERSION-linux-x86_64.AppImage" ` -LinuxAssetUrl $linuxUrl ` -WindowsBaseAiRuntimeAssetPath "dist/ai-runtime/OpenStudio-AI-Runtime-windows-base-x64.zip" ` @@ -546,16 +806,25 @@ jobs: -MetadataDir "dist/release-metadata" ` -OutputDir "dist/release-publish-assets" + - name: Add FFmpeg source archive to release checksums + shell: pwsh + run: | + $sourcePath = "dist/windows/OpenStudio-FFmpeg-8.0.1-complete-corresponding-source.zip" + $checksumsPath = "dist/release-publish-assets/OpenStudio-checksums.txt" + $sourceHash = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() + "$sourceHash OpenStudio-FFmpeg-8.0.1-complete-corresponding-source.zip" | + Add-Content -LiteralPath $checksumsPath -Encoding utf8 + - name: Publish GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('v{0}', github.event.inputs.version) }} name: OpenStudio ${{ env.VERSION }} body_path: ${{ env.RELEASE_NOTES_FILE }} fail_on_unmatched_files: true - overwrite_files: true files: | dist/windows/OpenStudio-Setup-x64.exe + dist/windows/OpenStudio-FFmpeg-8.0.1-complete-corresponding-source.zip dist/macos/OpenStudio-macOS.dmg dist/linux/OpenStudio-*-linux-x86_64.AppImage dist/release-publish-assets/OpenStudio-checksums.txt @@ -578,7 +847,7 @@ jobs: fi - name: Trigger website release publish - uses: peter-evans/repository-dispatch@v3 + uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3 with: token: ${{ secrets.OPENSTUDIO_WEBSITE_DISPATCH_TOKEN }} repository: ${{ env.WEBSITE_REPO }} diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 5d27263..337d200 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -7,19 +7,55 @@ on: pull_request: workflow_dispatch: +permissions: + contents: read + jobs: + verify-input-profiles: + name: Input profiles (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-14] + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version-file: frontend/.nvmrc + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Run frontend unit tests + run: npm test + + - name: Install Playwright Chromium + run: npx playwright install chromium + + - name: Run shortcut and input-profile browser tests + run: npm run test:e2e + verify-windows: runs-on: windows-latest env: VERSION: 0.0.0 BUILD_DIR: build-verify-windows ASIO_SDK_DIR: thirdparty/asio + ONNXRUNTIME_VERSION: 1.24.4 + ONNXRUNTIME_WIN_X64_SHA256: d2319fddfb6ea4db99ccc4b60c85c517bcd855721f5daa6a06d40d7cb2ee2357 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 20 + node-version-file: frontend/.nvmrc cache: npm cache-dependency-path: frontend/package-lock.json @@ -28,12 +64,18 @@ jobs: run: | cd frontend npm ci + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } npm run build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Install ASIO SDK shell: pwsh run: ./tools/setup-asio-sdk.ps1 -Destination $env:ASIO_SDK_DIR + - name: Install ONNX Runtime + shell: pwsh + run: ./tools/setup-onnxruntime.ps1 -Version $env:ONNXRUNTIME_VERSION -ExpectedSha256 $env:ONNXRUNTIME_WIN_X64_SHA256 + - name: Install Windows prerequisite installers shell: pwsh run: | @@ -112,17 +154,101 @@ jobs: throw "Windows startup self-test failed." } + - name: Regress WebView2 preflight from a non-writable install directory + shell: pwsh + run: | + $sourceBundle = Join-Path $env:GITHUB_WORKSPACE "$env:BUILD_DIR/OpenStudio_artefacts/Release" + $protectedBundle = Join-Path $env:RUNNER_TEMP "OpenStudio-Protected-Install" + Copy-Item -LiteralPath $sourceBundle -Destination $protectedBundle -Recurse + + # Model Program Files precisely: the test identity may read and + # execute the bundle but may not write beside OpenStudio.exe. + $acl = New-Object System.Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $none = [System.Security.AccessControl.PropagationFlags]::None + $allow = [System.Security.AccessControl.AccessControlType]::Allow + $deny = [System.Security.AccessControl.AccessControlType]::Deny + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name + # GitHub-hosted Windows runners execute as an administrator. An explicit + # user deny keeps the probe non-writable while the Administrators group + # retains the delete and ACL rights needed for runner cleanup. + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + $currentUser, 'Write', $inheritance, $none, $deny))) + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + $currentUser, 'ReadAndExecute, Synchronize', $inheritance, $none, $allow))) + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + 'NT AUTHORITY\SYSTEM', 'FullControl', $inheritance, $none, $allow))) + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + 'BUILTIN\Administrators', 'FullControl', $inheritance, $none, $allow))) + Set-Acl -LiteralPath $protectedBundle -AclObject $acl + + $writeProbe = Join-Path $protectedBundle 'must-not-write.tmp' + $writeDenied = $false + try { + [System.IO.File]::WriteAllText($writeProbe, 'probe') + } catch [System.UnauthorizedAccessException] { + $writeDenied = $true + } + if ((Test-Path -LiteralPath $writeProbe) -or -not $writeDenied) { + throw "Staged install directory remained writable." + } + + $report = Join-Path $env:RUNNER_TEMP "OpenStudio_ProtectedInstallStartupSelfTest.txt" + $exePath = Join-Path $protectedBundle "OpenStudio.exe" + $process = Start-Process -FilePath $exePath -ArgumentList @("--startup-self-test", "--report", "`"$report`"") -Wait -PassThru + if ($process.ExitCode -ne 0) { + if (Test-Path -LiteralPath $report) { + Get-Content -LiteralPath $report + } + throw "WebView2 preflight failed from a non-writable install directory." + } + + $reportText = [System.IO.File]::ReadAllText($report) + $userDataMatch = [regex]::Match($reportText, '(?m)^webView2UserDataPath=(.+)$') + if (-not $userDataMatch.Success) { + throw "Protected-install self-test did not report its WebView2 user-data folder." + } + $userDataPath = [System.IO.Path]::GetFullPath($userDataMatch.Groups[1].Value.Trim()) + $protectedRoot = [System.IO.Path]::GetFullPath($protectedBundle).TrimEnd('\') + '\' + if ($userDataPath.StartsWith($protectedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "WebView2 user data incorrectly resolves inside the protected install tree: $userDataPath" + } + + - name: Run Windows native-window lifecycle smoke test + shell: pwsh + run: | + $exePath = Join-Path $env:GITHUB_WORKSPACE "$env:BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.exe" + $report = Join-Path $env:RUNNER_TEMP "OpenStudio_WindowLifecycleHarness.json" + ./tools/run-window-lifecycle-smoke.ps1 -AppPath $exePath -ReportPath $report -TimeoutSeconds 180 + + - name: Run NAM Rack deterministic regression + shell: pwsh + run: | + $exePath = Join-Path $env:GITHUB_WORKSPACE "$env:BUILD_DIR/OpenStudio_artefacts/Release/OpenStudio.exe" + ./tools/run-nam-rack-headless-regression.ps1 ` + -AppPath $exePath ` + -OutputRoot $env:RUNNER_TEMP ` + -Label "ci-release" ` + -TimeoutSeconds 360 ` + -SkipBuild + verify-macos: - runs-on: macos-14 + name: macOS native windows (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-15-intel] env: VERSION: 0.0.0 BUILD_DIR: build-verify-macos steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 20 + node-version-file: frontend/.nvmrc cache: npm cache-dependency-path: frontend/package-lock.json @@ -159,6 +285,14 @@ jobs: shell: pwsh run: ./tools/validate-runtime-bundle.ps1 -Platform macos -BundlePath "$env:APP_BUNDLE" -ExpectedVersion "$env:VERSION" -EnforceLeanBundle + - name: Validate universal macOS executable + shell: bash + run: | + ARCHS="$(lipo -archs "$APP_BUNDLE/Contents/MacOS/OpenStudio")" + echo "OpenStudio architectures: $ARCHS" + [[ " $ARCHS " == *" arm64 "* ]] + [[ " $ARCHS " == *" x86_64 "* ]] + - name: Run macOS startup self-test shell: bash run: | @@ -171,14 +305,22 @@ jobs: exit 1 fi + - name: Run macOS native-window lifecycle smoke test + shell: pwsh + run: | + $executable = Join-Path $env:APP_BUNDLE "Contents/MacOS/OpenStudio" + $report = Join-Path $env:RUNNER_TEMP "OpenStudio_WindowLifecycleHarness.json" + ./tools/run-window-lifecycle-smoke.ps1 -AppPath $executable -ReportPath $report -TimeoutSeconds 180 + verify-linux: runs-on: ubuntu-24.04 env: VERSION: 0.0.0 BUILD_DIR: build-verify-linux ONNXRUNTIME_VERSION: 1.24.4 + ONNXRUNTIME_LINUX_X64_SHA256: 3a211fbea252c1e66290658f1b735b772056149f28321e71c308942cdb54b747 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Install system dependencies shell: bash @@ -186,9 +328,9 @@ jobs: bash ./tools/setup-linux-prereqs.sh sudo apt-get install -y xvfb - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 20 + node-version-file: frontend/.nvmrc cache: npm cache-dependency-path: frontend/package-lock.json @@ -204,6 +346,7 @@ jobs: run: | URL="https://github.com/microsoft/onnxruntime/releases/download/v${ONNXRUNTIME_VERSION}/onnxruntime-linux-x64-${ONNXRUNTIME_VERSION}.tgz" wget -q "$URL" -O onnxruntime.tgz + echo "${ONNXRUNTIME_LINUX_X64_SHA256} onnxruntime.tgz" | sha256sum --check --strict mkdir -p thirdparty/onnxruntime tar -xzf onnxruntime.tgz --strip-components=1 -C thirdparty/onnxruntime @@ -249,3 +392,21 @@ jobs: - name: Verify Linux AppImage output shell: bash run: test -f "dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" + + - name: Validate packaged Linux AppImage contents + shell: bash + run: | + APPIMAGE="$GITHUB_WORKSPACE/dist/linux/OpenStudio-${VERSION}-linux-x86_64.AppImage" + EXTRACT_ROOT="$(mktemp -d)" + cleanup() { rm -rf -- "$EXTRACT_ROOT"; } + trap cleanup EXIT + chmod +x "$APPIMAGE" + ( + cd "$EXTRACT_ROOT" + "$APPIMAGE" --appimage-extract >/dev/null + ) + pwsh -NoProfile -File "$GITHUB_WORKSPACE/tools/validate-runtime-bundle.ps1" \ + -Platform linux \ + -BundlePath "$EXTRACT_ROOT/squashfs-root/usr/bin" \ + -ExpectedVersion "$VERSION" \ + -EnforceLeanBundle diff --git a/.gitignore b/.gitignore index 6a1618c..f7c2a39 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ cmake_install.cmake # IDE .vs .vscode +.claude/settings.local.json *.sln *.vcxproj *.vcxproj.filters @@ -52,9 +53,28 @@ get-pip.py /tools/linuxdeploy-*.AppImage # Local pitch/render regression outputs +/qa/ +/.playwright-cli/ +/frontend/.playwright-cli/ +/.codex-key-diff.txt +/.codex_tmp/ +/output/ +/tmp_nam_rack_runs/ +/tmp_clean_guitar_runs/ +/docs/nam-rack-design-html/ +/docs/nam-rack-neural-clean-handoff/ +/docs/nam_visual_design/ +/docs/nam_visual_qa/ /tmp_pitch_runs/ /pitch_debug_captures/ /*_signal_chain_debug/ + +# Local maintainer planning (not for the public repository) +/docs/roadmap.internal.md + +# NAM design masters are archived outside the repo; runtime WebP assets stay tracked. +/frontend/src/assets/nam/design/**/*.png +/frontend/src/assets/nam/rack-studio-backdrop-v2.png /signal_chain_debug/ /tests/fixtures/pitch-regression/runs/ /tests/fixtures/pitch-regression/**/*.wav @@ -135,15 +155,14 @@ get-pip.py # ML models (large binary files, downloaded at setup/runtime) /resources/models/*.onnx +!/resources/models/basic_pitch_nmp.onnx /resources/models/*.ckpt /resources/models/*.bin /resources/models/*.ort # Audio test samples /audio_test_samples/ - -# Local QA screenshots/reports generated by browser and native harnesses -/qa/ +/resources/test_fixtures/ # Logs *.log diff --git a/AGENTS.md b/AGENTS.md index a93a64d..2b9495b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Studio13-v3 +# OpenStudio A hybrid DAW (Digital Audio Workstation) with a **JUCE C++ backend** for audio processing and a **React/TypeScript frontend** rendered in WebView2. @@ -26,7 +26,7 @@ C++ (JUCE) Backend React/TypeScript Frontend ## Directory Structure ``` -Studio13-v3/ +OpenStudio/ ├── Source/ # C++ backend │ ├── Main.cpp # JUCE app entry point │ ├── MainComponent.h/cpp # Hosts WebBrowserComponent + AudioEngine, exposes native functions to JS @@ -138,9 +138,9 @@ Studio13-v3/ ├── tools/ # ffmpeg.exe, stem_separator.py, setup scripts ├── resources/ # ONNX models, presets, resources ├── build/ # CMake build output -├── CMakeLists.txt # C++ build: JUCE 8.0.0, ASIO SDK, WebView2, VST3, ONNX Runtime +├── CMakeLists.txt # C++ build: JUCE 9.0.1, ASIO SDK, WebView2, VST3, ONNX Runtime ├── build.py # Python orchestrator: cmake + npm + vite dev server -├── pitch_corrector_feat_plan.md # Detailed pitch editor implementation plan (Melodyne/RePitch/VariAudio parity) +├── docs/roadmap.md # Open release/product work; completed implementation history stays in Git └── WORKFLOWS.md # Dev workflow docs ``` @@ -177,7 +177,7 @@ Before asking for manual testing: - Make sure the latest frontend code is built into `frontend/dist` when packaged fallback could be used. - Run `cmake --build build --config Debug` after frontend or C++ changes so the Debug app and copied `webui` assets are current. - Do not require the user to pre-run Vite, npm, or any other server. `python build.py dev --run` must start what it needs. -- Stop any Codex-started dev servers, harness browsers, or background Vite/npm processes before handing off. Verify port `5173` is not left occupied by a Codex-started process. +- Stop any Codex-started dev servers, harness browsers, or background Vite/npm processes before handing off. Verify port `5183` is not left occupied by a Codex-started process. - In the handoff, state that the CMake Debug build was completed and that no pre-running server is required. ## Key Technical Details @@ -232,7 +232,7 @@ For **continuous edits** (faders, knobs), use the begin/commit pattern: `beginXE ### Pitch Editor Subsystem -The pitch editor enables vocal pitch correction with both real-time (auto-tune style) and graphical (Melodyne-style) modes. The implementation plan for reaching Melodyne/RePitch/VariAudio quality is in `pitch_corrector_feat_plan.md`. +The pitch editor enables vocal pitch correction with both real-time (auto-tune style) and graphical (Melodyne-style) modes. Current open work and release decisions live in `docs/roadmap.md`; renderer evidence is retained only in the dedicated pitch research notes. **Architecture**: ``` @@ -310,6 +310,23 @@ Real-time corrector: - Semantic colors: `daw-record` (red), `daw-mute` (green), `daw-solo` (yellow), `daw-fx` (lime) - UI components in `components/ui/` use variant pattern (default, primary, success, danger, etc.) +### Frontend Styling and Visual QA + +- Do not generate stylesheet strings at runtime, mount JSX `
" + << "

" << safeTitle << "

" << safeDetail << "

"; + + juce::String response; + response << "HTTP/1.1 " << status << "\r\n" + << "Content-Type: text/html; charset=utf-8\r\n" + << "Content-Length: " << static_cast (html.getNumBytesAsUTF8()) << "\r\n" + << "Connection: close\r\n\r\n" + << html; + writeTone3000SocketText(socket, response, 3000); +} + +juce::String readTone3000HttpRequest(juce::StreamingSocket& socket, int timeoutMs) +{ + juce::MemoryOutputStream stream; + char buffer[1024] {}; + const auto deadline = juce::Time::currentTimeMillis() + timeoutMs; + + while (stream.getDataSize() < 16384 && juce::Time::currentTimeMillis() < deadline) + { + if (socket.waitUntilReady(true, 250) <= 0) + continue; + + const auto bytesRead = socket.read(buffer, static_cast (sizeof(buffer)), false); + if (bytesRead <= 0) + break; + + stream.write(buffer, static_cast (bytesRead)); + const auto text = juce::String::fromUTF8(static_cast (stream.getData()), static_cast (stream.getDataSize())); + if (text.contains("\r\n\r\n")) + return text; + } + + return juce::String::fromUTF8(static_cast (stream.getData()), static_cast (stream.getDataSize())); +} + +juce::String base64UrlEncode(const void* data, size_t size) +{ + auto text = juce::Base64::toBase64(data, size); + text = text.replace("+", "-").replace("/", "_"); + return text.trimCharactersAtEnd("="); +} + +juce::String makeTone3000FormBody(const juce::StringPairArray& values) +{ + juce::String body; + const auto keys = values.getAllKeys(); + const auto allValues = values.getAllValues(); + for (int i = 0; i < values.size(); ++i) + { + if (i > 0) + body << "&"; + body << juce::URL::addEscapeChars(keys[i], true) + << "=" + << juce::URL::addEscapeChars(allValues[i], true); + } + return body; +} + +juce::String makeTone3000CodeVerifier() +{ + auto verifier = (juce::Uuid().toString() + juce::Uuid().toString() + juce::Uuid().toString()) + .replace("-", "") + .replace("{", "") + .replace("}", ""); + return verifier.substring(0, 96); +} + +juce::String makeTone3000CodeChallenge(const juce::String& verifier) +{ + juce::MemoryBlock verifierData(verifier.toRawUTF8(), verifier.getNumBytesAsUTF8()); + const auto hash = juce::SHA256(verifierData).getRawData(); + return base64UrlEncode(hash.getData(), hash.getSize()); +} + +#if ! JUCE_WINDOWS +juce::String tone3000SecureItemName(const juce::File& legacyFile) +{ + return legacyFile.getFileName().containsIgnoreCase("pending") + ? juce::String("pending-auth") + : juce::String("oauth-token"); +} +#endif + +#if JUCE_MAC +struct Tone3000MacKeychainApi +{ + using Find = OSStatus (*) (CFTypeRef, UInt32, const char*, UInt32, + const char*, UInt32*, void**, + SecKeychainItemRef*); + using Add = OSStatus (*) (SecKeychainRef, UInt32, const char*, UInt32, + const char*, UInt32, const void*, + SecKeychainItemRef*); + using Modify = OSStatus (*) (SecKeychainItemRef, + const SecKeychainAttributeList*, + UInt32, const void*); + using Delete = OSStatus (*) (SecKeychainItemRef); + using FreeContent = OSStatus (*) (SecKeychainAttributeList*, void*); + + void* library = nullptr; + Find find = nullptr; + Add add = nullptr; + Modify modify = nullptr; + Delete remove = nullptr; + FreeContent freeContent = nullptr; + + Tone3000MacKeychainApi() + { + library = ::dlopen( + "/System/Library/Frameworks/Security.framework/Security", + RTLD_NOW | RTLD_LOCAL); + if (library == nullptr) + return; + find = reinterpret_cast(::dlsym( + library, "SecKeychainFindGenericPassword")); + add = reinterpret_cast(::dlsym( + library, "SecKeychainAddGenericPassword")); + modify = reinterpret_cast(::dlsym( + library, "SecKeychainItemModifyAttributesAndData")); + remove = reinterpret_cast(::dlsym( + library, "SecKeychainItemDelete")); + freeContent = reinterpret_cast(::dlsym( + library, "SecKeychainItemFreeContent")); + } + + ~Tone3000MacKeychainApi() + { + if (library != nullptr) + ::dlclose(library); + } + + bool available() const noexcept + { + return find != nullptr && add != nullptr && modify != nullptr + && remove != nullptr && freeContent != nullptr; + } +}; + +Tone3000MacKeychainApi& getTone3000MacKeychainApi() +{ + static Tone3000MacKeychainApi api; + return api; +} + +constexpr const char* kTone3000KeychainService = + "com.openstudio.OpenStudio.TONE3000"; + +bool loadTone3000MacKeychainText(const juce::String& item, + juce::String& text, + bool& found, + juce::String& error) +{ + text.clear(); + found = false; + error.clear(); + auto& api = getTone3000MacKeychainApi(); + if (! api.available()) + { + error = "macOS Keychain services are unavailable"; + return false; + } + + UInt32 dataLength = 0; + void* data = nullptr; + SecKeychainItemRef keychainItem = nullptr; + const auto account = item.toRawUTF8(); + const auto status = api.find( + nullptr, + static_cast(std::strlen(kTone3000KeychainService)), + kTone3000KeychainService, + static_cast(item.getNumBytesAsUTF8()), + account, + &dataLength, + &data, + &keychainItem); + if (status == errSecItemNotFound) + return true; + if (status != errSecSuccess) + { + error = "Could not read the TONE3000 session from macOS Keychain (" + + juce::String(static_cast(status)) + ")"; + return false; + } + + found = true; + text = juce::String::fromUTF8( + static_cast(data), static_cast(dataLength)); + if (data != nullptr) + api.freeContent(nullptr, data); + if (keychainItem != nullptr) + CFRelease(keychainItem); + return true; +} + +bool saveTone3000MacKeychainText(const juce::String& item, + const juce::String& text, + juce::String& error) +{ + error.clear(); + auto& api = getTone3000MacKeychainApi(); + if (! api.available()) + { + error = "macOS Keychain services are unavailable"; + return false; + } + + UInt32 existingLength = 0; + void* existingData = nullptr; + SecKeychainItemRef keychainItem = nullptr; + const auto account = item.toRawUTF8(); + const auto findStatus = api.find( + nullptr, + static_cast(std::strlen(kTone3000KeychainService)), + kTone3000KeychainService, + static_cast(item.getNumBytesAsUTF8()), + account, + &existingLength, + &existingData, + &keychainItem); + if (findStatus == errSecSuccess && existingData != nullptr) + api.freeContent(nullptr, existingData); + + const auto* bytes = text.toRawUTF8(); + const auto byteCount = static_cast(text.getNumBytesAsUTF8()); + OSStatus status = errSecSuccess; + if (findStatus == errSecSuccess && keychainItem != nullptr) + { + status = api.modify(keychainItem, nullptr, byteCount, bytes); + CFRelease(keychainItem); + } + else if (findStatus == errSecItemNotFound) + { + status = api.add( + nullptr, + static_cast(std::strlen(kTone3000KeychainService)), + kTone3000KeychainService, + static_cast(item.getNumBytesAsUTF8()), + account, + byteCount, + bytes, + nullptr); + } + else + { + status = findStatus == errSecSuccess + ? errSecInvalidItemRef + : findStatus; + if (keychainItem != nullptr) + CFRelease(keychainItem); + } + + if (status != errSecSuccess) + { + error = "Could not store the TONE3000 session in macOS Keychain (" + + juce::String(static_cast(status)) + ")"; + return false; + } + return true; +} + +bool deleteTone3000MacKeychainText(const juce::String& item) +{ + auto& api = getTone3000MacKeychainApi(); + if (! api.available()) + return false; + + UInt32 dataLength = 0; + void* data = nullptr; + SecKeychainItemRef keychainItem = nullptr; + const auto account = item.toRawUTF8(); + const auto findStatus = api.find( + nullptr, + static_cast(std::strlen(kTone3000KeychainService)), + kTone3000KeychainService, + static_cast(item.getNumBytesAsUTF8()), + account, + &dataLength, + &data, + &keychainItem); + if (findStatus == errSecItemNotFound) + return true; + if (findStatus != errSecSuccess) + return false; + if (data != nullptr) + api.freeContent(nullptr, data); + if (keychainItem == nullptr) + return false; + const auto deleteStatus = api.remove(keychainItem); + CFRelease(keychainItem); + return deleteStatus == errSecSuccess; +} +#elif JUCE_LINUX +juce::File findTone3000SecretTool() +{ + for (const auto& candidate : { + juce::File("/usr/bin/secret-tool"), + juce::File("/bin/secret-tool") }) + { + if (candidate.existsAsFile()) + return candidate; + } + return {}; +} + +bool runTone3000SecretTool(const juce::StringArray& arguments, + juce::String& output, + int& exitCode) +{ + const auto tool = findTone3000SecretTool(); + if (! tool.existsAsFile()) + return false; + juce::StringArray command; + command.add(tool.getFullPathName()); + for (const auto& argument : arguments) + command.add(argument); + juce::ChildProcess process; + if (! process.start(command)) + return false; + const auto deadline = + juce::Time::getMillisecondCounterHiRes() + 30000.0; + while (process.isRunning() + && juce::Time::getMillisecondCounterHiRes() < deadline + && ! isTone3000TaskCancelled()) + { + juce::Thread::sleep(25); + } + if (process.isRunning()) + { + process.kill(); + exitCode = -1; + return true; + } + exitCode = process.getExitCode(); + output = process.readAllProcessOutput(); + return true; +} + +bool runTone3000SecretToolWithInput(const juce::StringArray& arguments, + const juce::String& input, + int& exitCode) +{ + exitCode = -1; + const auto tool = findTone3000SecretTool(); + if (! tool.existsAsFile()) + return false; + + int inputPipe[2] { -1, -1 }; + if (::pipe(inputPipe) != 0) + return false; + + posix_spawn_file_actions_t actions; + if (::posix_spawn_file_actions_init(&actions) != 0) + { + ::close(inputPipe[0]); + ::close(inputPipe[1]); + return false; + } + const bool actionsReady = + ::posix_spawn_file_actions_adddup2( + &actions, inputPipe[0], STDIN_FILENO) == 0 + && ::posix_spawn_file_actions_addclose( + &actions, inputPipe[1]) == 0 + && ::posix_spawn_file_actions_addopen( + &actions, STDOUT_FILENO, "/dev/null", O_WRONLY, 0) == 0 + && ::posix_spawn_file_actions_addopen( + &actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0) == 0; + if (! actionsReady) + { + ::posix_spawn_file_actions_destroy(&actions); + ::close(inputPipe[0]); + ::close(inputPipe[1]); + return false; + } + + std::vector ownedArguments; + ownedArguments.reserve(static_cast(arguments.size()) + 1); + ownedArguments.push_back(tool.getFullPathName().toStdString()); + for (const auto& argument : arguments) + ownedArguments.push_back(argument.toStdString()); + std::vector argv; + argv.reserve(ownedArguments.size() + 1); + for (auto& argument : ownedArguments) + argv.push_back(argument.data()); + argv.push_back(nullptr); + + pid_t child = 0; + const int spawnStatus = ::posix_spawn( + &child, + ownedArguments.front().c_str(), + &actions, + nullptr, + argv.data(), + environ); + ::posix_spawn_file_actions_destroy(&actions); + ::close(inputPipe[0]); + if (spawnStatus != 0) + { + ::close(inputPipe[1]); + return false; + } + + sigset_t blockedSignals; + sigset_t previousSignals; + ::sigemptyset(&blockedSignals); + ::sigaddset(&blockedSignals, SIGPIPE); + if (::pthread_sigmask( + SIG_BLOCK, &blockedSignals, &previousSignals) != 0) + { + ::close(inputPipe[1]); + ::kill(child, SIGKILL); + (void) ::waitpid(child, nullptr, 0); + return false; + } + sigset_t pendingBefore; + ::sigpending(&pendingBefore); + const bool sigpipeWasPending = ::sigismember(&pendingBefore, SIGPIPE) == 1; + + const auto utf8 = input.toUTF8(); + size_t written = 0; + while (written < utf8.sizeInBytes() - 1) + { + if (isTone3000TaskCancelled()) + break; + const auto count = ::write( + inputPipe[1], + utf8.getAddress() + written, + utf8.sizeInBytes() - 1 - written); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + break; + written += static_cast(count); + } + ::close(inputPipe[1]); + if (! sigpipeWasPending) + { + timespec noWait { 0, 0 }; + (void) ::sigtimedwait(&blockedSignals, nullptr, &noWait); + } + (void) ::pthread_sigmask(SIG_SETMASK, &previousSignals, nullptr); + + if (isTone3000TaskCancelled()) + { + ::kill(child, SIGKILL); + (void) ::waitpid(child, nullptr, 0); + exitCode = -1; + return true; + } + + int childStatus = 0; + bool childReaped = false; + const auto deadline = juce::Time::getMillisecondCounterHiRes() + 30000.0; + for (;;) + { + const auto waitResult = ::waitpid(child, &childStatus, WNOHANG); + if (waitResult == child) + { + childReaped = true; + break; + } + if (waitResult < 0 && errno != EINTR) + break; + if (isTone3000TaskCancelled() + || juce::Time::getMillisecondCounterHiRes() >= deadline) + { + ::kill(child, SIGKILL); + (void) ::waitpid(child, &childStatus, 0); + exitCode = -1; + return true; + } + juce::Thread::sleep(10); + } + if (! childReaped) + { + ::kill(child, SIGKILL); + (void) ::waitpid(child, &childStatus, 0); + } + exitCode = childReaped && WIFEXITED(childStatus) + ? WEXITSTATUS(childStatus) + : -1; + return written == utf8.sizeInBytes() - 1; +} + +bool saveTone3000LinuxSecret(const juce::String& item, + const juce::String& text, + juce::String& error) +{ + error.clear(); + const auto tool = findTone3000SecretTool(); + if (! tool.existsAsFile()) + { + error = "Secret Service is unavailable (secret-tool was not found)"; + return false; + } + + juce::StringArray arguments; + arguments.add("store"); + arguments.add("--label=OpenStudio TONE3000"); + arguments.add("service"); + arguments.add("com.openstudio.OpenStudio.TONE3000"); + arguments.add("item"); + arguments.add(item); + int status = -1; + if (! runTone3000SecretToolWithInput(arguments, text, status) + || status != 0) + { + error = "Could not store the TONE3000 session in Secret Service"; + return false; + } + return true; +} + +bool loadTone3000LinuxSecret(const juce::String& item, + juce::String& text, + bool& found, + juce::String& error) +{ + text.clear(); + found = false; + error.clear(); + juce::StringArray arguments; + arguments.add("lookup"); + arguments.add("service"); + arguments.add("com.openstudio.OpenStudio.TONE3000"); + arguments.add("item"); + arguments.add(item); + int status = -1; + juce::String output; + if (! runTone3000SecretTool(arguments, output, status)) + { + error = "Secret Service is unavailable (secret-tool was not found)"; + return false; + } + found = status == 0 && output.isNotEmpty(); + if (status != 0 && status != 1) + { + error = "Could not read the TONE3000 session from Secret Service"; + return false; + } + if (found) + text = output.trimCharactersAtEnd("\r\n"); + return true; +} + +bool deleteTone3000LinuxSecret(const juce::String& item) +{ + juce::StringArray arguments; + arguments.add("clear"); + arguments.add("service"); + arguments.add("com.openstudio.OpenStudio.TONE3000"); + arguments.add("item"); + arguments.add(item); + int status = -1; + juce::String output; + return runTone3000SecretTool(arguments, output, status) + && (status == 0 || status == 1); +} +#endif + +bool saveProtectedText(const juce::File& file, const juce::String& text, juce::String& error) +{ + const auto directoryResult = file.getParentDirectory().createDirectory(); + if (directoryResult.failed()) + { + error = "Could not create the private TONE3000 session directory"; + return false; + } + +#if JUCE_WINDOWS + juce::MemoryBlock plain(text.toRawUTF8(), text.getNumBytesAsUTF8()); + DATA_BLOB input {}; + input.pbData = static_cast(plain.getData()); + input.cbData = static_cast(plain.getSize()); + + DATA_BLOB output {}; + if (!::CryptProtectData(&input, L"OpenStudio TONE3000", nullptr, nullptr, nullptr, + CRYPTPROTECT_UI_FORBIDDEN, &output)) + { + error = "DPAPI encryption failed"; + return false; + } + + juce::TemporaryFile temporaryFile(file, juce::TemporaryFile::useHiddenFile); + const bool wroteTemporary = temporaryFile.getFile().replaceWithData(output.pbData, output.cbData); + ::LocalFree(output.pbData); + if (! wroteTemporary || ! temporaryFile.overwriteTargetFileWithTemporary()) + { + error = "Could not write encrypted token file"; + return false; + } + return true; +#elif JUCE_MAC + if (! saveTone3000MacKeychainText( + tone3000SecureItemName(file), text, error)) + return false; + // Successful secure publication completes migration from pre-release + // plaintext storage. + if (file.existsAsFile() && ! file.deleteFile()) + { + (void) deleteTone3000MacKeychainText( + tone3000SecureItemName(file)); + error = "Could not remove the legacy plaintext TONE3000 session file"; + return false; + } + return true; +#elif JUCE_LINUX + if (! saveTone3000LinuxSecret( + tone3000SecureItemName(file), text, error)) + return false; + if (file.existsAsFile() && ! file.deleteFile()) + { + (void) deleteTone3000LinuxSecret( + tone3000SecureItemName(file)); + error = "Could not remove the legacy plaintext TONE3000 session file"; + return false; + } + return true; +#else + juce::ignoreUnused(text); + error = "Secure TONE3000 token storage is unavailable on this platform"; + return false; +#endif +} + +juce::String loadProtectedText(const juce::File& file, juce::String& error) +{ +#if JUCE_WINDOWS + if (!file.existsAsFile()) + return {}; + juce::MemoryBlock encrypted; + if (!file.loadFileAsData(encrypted) || encrypted.getSize() == 0) + { + error = "Encrypted token file is empty"; + return {}; + } + + DATA_BLOB input {}; + input.pbData = static_cast(encrypted.getData()); + input.cbData = static_cast(encrypted.getSize()); + + DATA_BLOB output {}; + if (!::CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr, + CRYPTPROTECT_UI_FORBIDDEN, &output)) + { + error = "DPAPI decryption failed"; + return {}; + } + + const juce::String text = juce::String::fromUTF8(reinterpret_cast(output.pbData), + static_cast(output.cbData)); + ::LocalFree(output.pbData); + return text; +#elif JUCE_MAC || JUCE_LINUX + juce::String secureText; + bool found = false; + #if JUCE_MAC + const bool secureRead = loadTone3000MacKeychainText( + tone3000SecureItemName(file), secureText, found, error); + #else + const bool secureRead = loadTone3000LinuxSecret( + tone3000SecureItemName(file), secureText, found, error); + #endif + if (! secureRead) + return {}; + if (found) + { + if (file.existsAsFile() && ! file.deleteFile()) + { + error = "Could not remove the legacy plaintext TONE3000 session file"; + return {}; + } + return secureText; + } + + // One-time migration for builds which stored mode-0600 JSON. Do not use + // the legacy value unless it was successfully committed to OS storage; + // this prevents silently retaining refresh tokens on disk. + if (! file.existsAsFile()) + return {}; + const auto legacyText = file.loadFileAsString(); + if (legacyText.isEmpty()) + { + error = "Legacy TONE3000 session file is empty"; + return {}; + } + juce::String migrationError; + if (! saveProtectedText(file, legacyText, migrationError)) + { + error = "Could not migrate the TONE3000 session to secure storage: " + + migrationError; + return {}; + } + return legacyText; +#else + error = "Secure TONE3000 token storage is unavailable on this platform"; + return {}; +#endif +} + +bool deleteProtectedText(const juce::File& file) +{ +#if JUCE_WINDOWS + return ! file.existsAsFile() || file.deleteFile(); +#elif JUCE_MAC + const bool deleted = deleteTone3000MacKeychainText( + tone3000SecureItemName(file)); + const bool legacyDeleted = ! file.existsAsFile() || file.deleteFile(); + return deleted && legacyDeleted; +#elif JUCE_LINUX + const bool deleted = deleteTone3000LinuxSecret( + tone3000SecureItemName(file)); + const bool legacyDeleted = ! file.existsAsFile() || file.deleteFile(); + return deleted && legacyDeleted; +#else + return ! file.existsAsFile() || file.deleteFile(); +#endif +} + +bool isTone3000SecureStorageAvailable() +{ +#if JUCE_WINDOWS + return true; +#elif JUCE_MAC + return getTone3000MacKeychainApi().available(); +#elif JUCE_LINUX + return findTone3000SecretTool().existsAsFile(); +#else + return false; +#endif +} + +juce::var loadProtectedJson(const juce::File& file, juce::String& error) +{ + const auto text = loadProtectedText(file, error); + if (text.isEmpty()) + return {}; + + auto parsed = juce::JSON::parse(text); + if (parsed.isVoid()) + error = "Stored TONE3000 token JSON is invalid"; + return parsed; +} + +bool saveProtectedJson(const juce::File& file, const juce::var& payload, juce::String& error) +{ + return saveProtectedText(file, juce::JSON::toString(payload, false), error); +} + +juce::var makeTone3000Error(const juce::String& message, int statusCode = 0) +{ + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + result->setProperty("error", message); + if (statusCode > 0) + result->setProperty("statusCode", statusCode); + return juce::var(result.get()); +} + +bool readTone3000ResponseText( + juce::InputStream& input, + juce::String& text, + juce::String& error, + size_t maximumBytes) +{ + juce::MemoryOutputStream output; + std::array buffer {}; + while (! input.isExhausted()) + { + if (isTone3000TaskCancelled()) + { + error = "The NAM/TONE3000 request was canceled."; + return false; + } + const int bytesRead = input.read( + buffer.data(), static_cast(buffer.size())); + if (bytesRead <= 0) + break; + if (output.getDataSize() + + static_cast(bytesRead) + > maximumBytes) + { + error = "The TONE3000 response exceeded its safety limit."; + return false; + } + if (! output.write( + buffer.data(), static_cast(bytesRead))) + { + error = "Could not buffer the TONE3000 response."; + return false; + } + } + + text = juce::String::fromUTF8( + static_cast(output.getData()), + static_cast(output.getDataSize())); + error.clear(); + return true; +} + +juce::var postTone3000OAuthToken(const juce::StringPairArray& fields) +{ + if (isTone3000TaskCancelled()) + return makeTone3000Error("The TONE3000 token request was canceled."); + int statusCode = 0; + auto tokenUrl = juce::URL("https://www.tone3000.com/api/v1/oauth/token") + .withPOSTData(makeTone3000FormBody(fields)); + auto input = tokenUrl.createInputStream( + juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withHttpRequestCmd("POST") + .withExtraHeaders("Content-Type: application/x-www-form-urlencoded\r\n") + .withConnectionTimeoutMs(30000) + .withNumRedirectsToFollow(2) + .withStatusCode(&statusCode)); + + if (input == nullptr) + return makeTone3000Error("TONE3000 token request failed", statusCode); + + juce::String responseText; + juce::String responseError; + if (! readTone3000ResponseText( + *input, + responseText, + responseError, + 1024 * 1024)) + { + return makeTone3000Error(responseError, statusCode); + } + auto parsed = juce::JSON::parse(responseText); + if (parsed.isVoid()) + return makeTone3000Error("TONE3000 token response was not valid JSON", statusCode); + + if (statusCode >= 400) + { + auto* errorObject = parsed.getDynamicObject(); + juce::String message = "Token request failed"; + juce::String oauthError; + juce::String oauthDescription; + if (errorObject != nullptr) + { + oauthDescription = errorObject->getProperty("error_description").toString(); + oauthError = errorObject->getProperty("error").toString(); + message = oauthDescription; + if (message.isEmpty()) + message = oauthError; + if (message.isEmpty()) + message = "Token request failed"; + } + auto result = makeTone3000Error(message, statusCode); + if (auto* resultObject = result.getDynamicObject()) + { + if (oauthError.isNotEmpty()) + resultObject->setProperty("oauthError", oauthError); + if (oauthDescription.isNotEmpty()) + resultObject->setProperty("oauthErrorDescription", oauthDescription); + } + return result; + } + + return parsed; +} + +juce::var storeTone3000TokenPayloadUnlocked( + juce::var tokenPayload, + const juce::String& clientId, + juce::uint64 credentialEpoch) +{ + auto* tokenObject = tokenPayload.getDynamicObject(); + if (tokenObject == nullptr) + return makeTone3000Error("Invalid TONE3000 token payload"); + + const auto accessToken = tokenObject->getProperty("access_token").toString(); + const auto refreshToken = tokenObject->getProperty("refresh_token").toString(); + if (accessToken.isEmpty()) + return makeTone3000Error("TONE3000 did not return an access token"); + + const auto expiresIn = static_cast(static_cast(tokenObject->getProperty("expires_in"))); + const auto expiresAtMs = juce::Time::getCurrentTime().toMilliseconds() + + std::max(0, expiresIn) * 1000; + + juce::DynamicObject::Ptr stored = new juce::DynamicObject(); + stored->setProperty("schemaVersion", 1); + stored->setProperty("provider", "tone3000"); + stored->setProperty( + "credentialEpoch", + static_cast(credentialEpoch)); + stored->setProperty( + "credentialSession", tone3000CredentialSessionId); + stored->setProperty( + "credentialRevision", juce::Uuid().toString()); + stored->setProperty("clientId", clientId); + stored->setProperty("accessToken", accessToken); + stored->setProperty("refreshToken", refreshToken); + stored->setProperty("tokenType", tokenObject->hasProperty("token_type") + ? tokenObject->getProperty("token_type") + : juce::var("bearer")); + stored->setProperty("scope", tokenObject->hasProperty("scope") + ? tokenObject->getProperty("scope") + : juce::var()); + stored->setProperty("expiresAtMs", static_cast(expiresAtMs)); + stored->setProperty("storedAt", juce::Time::getCurrentTime().toISO8601(true)); + + juce::String error; + if (!saveProtectedJson(getTone3000TokenFile(), juce::var(stored.get()), error)) + return makeTone3000Error(error); + + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", true); + result->setProperty("authenticated", true); + result->setProperty("expired", false); + result->setProperty("clientId", clientId); + result->setProperty("expiresAtMs", static_cast(expiresAtMs)); + result->setProperty("hasRefreshToken", refreshToken.isNotEmpty()); + return juce::var(result.get()); +} + +juce::var makeTone3000AuthStatus() +{ + juce::String error; + juce::var stored; + { + const std::lock_guard storageGuard( + tone3000TokenStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (processGuard.locked) + stored = loadProtectedJson(getTone3000TokenFile(), error); + else + error = "Timed out waiting for another OpenStudio instance to finish updating TONE3000 credentials."; + } + juce::DynamicObject::Ptr status = new juce::DynamicObject(); + status->setProperty("success", true); + status->setProperty("authenticated", false); + status->setProperty("configuredClientId", getConfiguredTone3000ClientId().isNotEmpty()); + status->setProperty("defaultRedirectUri", kTone3000DefaultRedirectUri); + status->setProperty("authFlowActive", tone3000AuthFlowActive.load()); + + if (auto* object = stored.getDynamicObject()) + { + const auto expiresAtMs = static_cast(static_cast(object->getProperty("expiresAtMs"))); + const auto nowMs = juce::Time::getCurrentTime().toMilliseconds(); + status->setProperty("authenticated", object->getProperty("accessToken").toString().isNotEmpty()); + status->setProperty("expired", expiresAtMs > 0 && nowMs >= expiresAtMs); + status->setProperty("expiresAtMs", static_cast(expiresAtMs)); + status->setProperty("clientId", object->getProperty("clientId")); + status->setProperty("hasRefreshToken", object->getProperty("refreshToken").toString().isNotEmpty()); + } + else if (error.isNotEmpty()) + { + status->setProperty("error", error); + } + + const bool authenticated = static_cast(status->getProperty("authenticated")); + const bool expired = static_cast(status->getProperty("expired")); + const bool configured = static_cast(status->getProperty("configuredClientId")) + || status->getProperty("clientId").toString().isNotEmpty(); + status->setProperty("integrationState", + authenticated && ! expired ? "authenticated" + : authenticated && expired ? "expired" + : configured ? "configured_unauthenticated" + : "client_id_required"); + status->setProperty("authenticatedQAReady", authenticated && ! expired); + status->setProperty("authenticatedQANote", authenticated && ! expired + ? "Authenticated TONE3000 integration checks may run without exposing the stored token." + : "Authenticated TONE3000 QA requires a user-connected, non-expired session."); + status->setProperty("oauthRedirectUri", kTone3000DefaultRedirectUri); + status->setProperty("oauthProvider", "tone3000.com"); +#if JUCE_WINDOWS + status->setProperty("secureTokenStorage", "windows-dpapi"); +#elif JUCE_MAC + status->setProperty("secureTokenStorage", + isTone3000SecureStorageAvailable() + ? "macos-keychain" + : "unavailable"); +#elif JUCE_LINUX + status->setProperty("secureTokenStorage", + isTone3000SecureStorageAvailable() + ? "freedesktop-secret-service" + : "unavailable"); +#else + status->setProperty("secureTokenStorage", "unavailable"); +#endif + status->setProperty("secureTokenStorageAvailable", + isTone3000SecureStorageAvailable()); + + return juce::var(status.get()); +} + +juce::String getStoredTone3000AccessToken() +{ + juce::String error; + juce::var stored; + { + const std::lock_guard storageGuard( + tone3000TokenStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (processGuard.locked) + stored = loadProtectedJson(getTone3000TokenFile(), error); + else + error = "Timed out waiting for another OpenStudio instance to finish updating TONE3000 credentials."; + } + if (auto* object = stored.getDynamicObject()) + { + const auto expiresAtMs = static_cast(static_cast(object->getProperty("expiresAtMs"))); + if (expiresAtMs <= 0 || juce::Time::getCurrentTime().toMilliseconds() < expiresAtMs) + return object->getProperty("accessToken").toString(); + } + return {}; +} + +juce::String getModelObjectString(juce::DynamicObject* model, + const juce::String& primary, + const juce::String& fallback = {}); + +int getModelObjectInt(juce::DynamicObject* model, + const juce::String& primary, + const juce::String& fallback = {}); + +bool isTone3000ErrorPayload(const juce::var& payload) +{ + if (auto* object = payload.getDynamicObject()) + return object->hasProperty("error") && static_cast(object->getProperty("success")) == false; + + return false; +} + +juce::var makeTone3000Success() +{ + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", true); + return juce::var(result.get()); +} + +double parseTone3000RetryAfterSeconds(const juce::String& headerValue) +{ + double seconds = headerValue.trim().isNotEmpty() + ? headerValue.getDoubleValue() + : 15.0; + if (! std::isfinite(seconds) || seconds <= 0.0) + seconds = 15.0; + return juce::jlimit(1.0, 60.0, seconds); +} + +juce::var getTone3000Json(juce::URL url, const juce::String& accessToken, const juce::String& label) +{ + if (isTone3000TaskCancelled()) + return makeTone3000Error(label + " was canceled"); + if (accessToken.isEmpty()) + return makeTone3000Error("Missing TONE3000 access token"); + if (accessToken.containsAnyOf("\r\n")) + return makeTone3000Error("The TONE3000 access token contains invalid header characters"); + + int statusCode = 0; + juce::StringPairArray responseHeaders; + juce::String headers; + headers << "Authorization: Bearer " << accessToken << "\r\n" + << "Content-Type: application/json\r\n"; + + auto input = url.createInputStream( + juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withExtraHeaders(headers) + .withConnectionTimeoutMs(30000) + .withNumRedirectsToFollow(2) + .withStatusCode(&statusCode) + .withResponseHeaders(&responseHeaders)); + + const auto attachRateLimitMetadata = [&responseHeaders, &statusCode] + (juce::var result) + { + if (statusCode != 429) + return result; + juce::String retryAfter; + for (int index = 0; index < responseHeaders.size(); ++index) + { + if (responseHeaders.getAllKeys()[index] + .equalsIgnoreCase("Retry-After")) + { + retryAfter = responseHeaders.getAllValues()[index].trim(); + break; + } + } + const double retryAfterSeconds = + parseTone3000RetryAfterSeconds(retryAfter); + if (auto* object = result.getDynamicObject()) + { + object->setProperty("retryAfterSeconds", retryAfterSeconds); + if (retryAfter.isNotEmpty()) + object->setProperty("retryAfter", retryAfter); + } + return result; + }; + + if (input == nullptr) + return attachRateLimitMetadata( + makeTone3000Error(label + " failed", statusCode)); + + juce::String responseText; + juce::String responseError; + if (! readTone3000ResponseText( + *input, + responseText, + responseError, + 32 * 1024 * 1024)) + { + return attachRateLimitMetadata( + makeTone3000Error(responseError, statusCode)); + } + auto parsed = juce::JSON::parse(responseText); + if (parsed.isVoid()) + return attachRateLimitMetadata(makeTone3000Error( + label + " response was not valid JSON", statusCode)); + + if (statusCode >= 400) + { + auto* errorObject = parsed.getDynamicObject(); + juce::String message = label + " failed"; + if (statusCode == 401) + message = "TONE3000 authentication expired. Refresh or reconnect."; + else if (statusCode == 429) + message = "TONE3000 rate limit reached. Wait a minute before searching again."; + + if (errorObject != nullptr) + { + const auto description = errorObject->getProperty("error_description").toString(); + const auto error = errorObject->getProperty("error").toString(); + if (description.isNotEmpty()) + message = description; + else if (error.isNotEmpty() && statusCode != 429) + message = error; + } + + auto result = makeTone3000Error(message, statusCode); + if (auto* resultObject = result.getDynamicObject()) + resultObject->setProperty("response", parsed); + return attachRateLimitMetadata(result); + } + + return parsed; +} + +juce::String normaliseTone3000Architecture(juce::String architecture) +{ + architecture = architecture.trim().toLowerCase(); + if (architecture == "a1") + return "1"; + if (architecture == "a2") + return "2"; + if (architecture == "1" || architecture == "2" || architecture == "custom") + return architecture; + return {}; +} + +juce::StringArray makeTone3000ArchitectureRequests(const juce::String& architecture) +{ + const auto normalised = normaliseTone3000Architecture(architecture); + juce::StringArray architectures; + if (normalised.isNotEmpty()) + { + architectures.add(normalised); + return architectures; + } + + architectures.add("2"); + architectures.add("1"); + return architectures; +} + +juce::String normaliseTone3000Sort(juce::String sort) +{ + sort = sort.trim().toLowerCase(); + if (sort == "newest" || sort == "oldest" || sort == "trending" || sort == "downloads-all-time" || sort == "best-match") + return sort; + if (sort == "latest") + return "newest"; + if (sort == "downloaded") + return "downloads-all-time"; + return "trending"; +} + +juce::String normaliseTone3000Gears(juce::String gears) +{ + gears = gears.trim().toLowerCase(); + return gears.replace(",", "_"); +} + +constexpr int kTone3000CatalogModelPageSize = 100; +constexpr int kTone3000MaximumModelPages = 20; + +struct Tone3000ModelPaginationResult +{ + juce::Array models; + juce::var error; + int pagesFetched = 0; +}; + +struct Tone3000PaginationInteger +{ + bool present = false; + bool valid = true; + int value = 0; +}; + +Tone3000PaginationInteger readTone3000PaginationInteger( + juce::DynamicObject* object, + const juce::String& primary, + const juce::String& fallback = {}) +{ + Tone3000PaginationInteger result; + if (object == nullptr) + return result; + + juce::var value; + if (object->hasProperty(primary)) + { + result.present = true; + value = object->getProperty(primary); + } + else if (fallback.isNotEmpty() && object->hasProperty(fallback)) + { + result.present = true; + value = object->getProperty(fallback); + } + else + { + return result; + } + + if (value.isVoid()) + return result; + + double numericValue = 0.0; + if (value.isInt() || value.isInt64() || value.isDouble()) + { + numericValue = static_cast(value); + } + else if (value.isString()) + { + const auto text = value.toString().trim(); + if (text.isEmpty() || ! text.containsOnly("0123456789")) + { + result.valid = false; + return result; + } + numericValue = text.getDoubleValue(); + } + else + { + result.valid = false; + return result; + } + + if (! std::isfinite(numericValue) + || numericValue < 0.0 + || numericValue > static_cast( + std::numeric_limits::max()) + || std::floor(numericValue) != numericValue) + { + result.valid = false; + return result; + } + + result.value = static_cast(numericValue); + return result; +} + +struct Tone3000PaginationBoolean +{ + bool present = false; + bool valid = true; + bool value = false; +}; + +Tone3000PaginationBoolean readTone3000PaginationBoolean( + juce::DynamicObject* object, + const juce::String& primary, + const juce::String& fallback = {}) +{ + Tone3000PaginationBoolean result; + if (object == nullptr) + return result; + + juce::var value; + if (object->hasProperty(primary)) + { + result.present = true; + value = object->getProperty(primary); + } + else if (fallback.isNotEmpty() && object->hasProperty(fallback)) + { + result.present = true; + value = object->getProperty(fallback); + } + else + { + return result; + } + + if (value.isBool()) + { + result.value = static_cast(value); + return result; + } + if (value.isInt()) + { + const int numericValue = static_cast(value); + result.valid = numericValue == 0 || numericValue == 1; + result.value = numericValue == 1; + return result; + } + if (value.isString()) + { + const auto text = value.toString().trim().toLowerCase(); + result.valid = text == "true" || text == "false"; + result.value = text == "true"; + return result; + } + + result.valid = false; + return result; +} + +Tone3000ModelPaginationResult fetchTone3000ModelPages( + int toneId, + const juce::String& architecture, + int requestedPageSize, + const std::function& requestPage) +{ + Tone3000ModelPaginationResult result; + juce::Array seenModelIds; + const int pageSize = juce::jlimit( + 1, kTone3000CatalogModelPageSize, requestedPageSize); + const auto expectedArchitecture = + normaliseTone3000Architecture(architecture); + + for (int page = 1; page <= kTone3000MaximumModelPages; ++page) + { + if (isTone3000TaskCancelled()) + { + result.error = makeTone3000Error( + "TONE3000 model pagination was canceled"); + return result; + } + + auto payload = requestPage(page, pageSize); + if (isTone3000ErrorPayload(payload)) + { + result.error = payload; + return result; + } + + auto* payloadObject = payload.getDynamicObject(); + if (payloadObject == nullptr) + { + result.error = makeTone3000Error( + "TONE3000 model list returned an invalid response"); + return result; + } + + auto data = payloadObject->getProperty("data"); + if (! data.isArray()) + data = payloadObject->getProperty("models"); + auto* models = data.getArray(); + if (models == nullptr) + { + result.error = makeTone3000Error( + "TONE3000 model list did not include a model array"); + return result; + } + + ++result.pagesFetched; + for (auto modelValue : *models) + { + auto* model = modelValue.getDynamicObject(); + if (model == nullptr) + continue; + + const int modelId = getModelObjectInt( + model, "id", "model_id"); + const int modelToneId = getModelObjectInt( + model, "tone_id", "toneId"); + const auto modelArchitecture = + normaliseTone3000Architecture(getModelObjectString( + model, + "architecture_version", + "architecture")); + if (modelId <= 0 + || (modelToneId > 0 && modelToneId != toneId) + || (expectedArchitecture.isNotEmpty() + && modelArchitecture.isNotEmpty() + && modelArchitecture != expectedArchitecture) + || seenModelIds.contains(modelId)) + { + continue; + } + + seenModelIds.add(modelId); + auto architectureValue = + model->getProperty("architecture_version"); + if (architectureValue.isVoid()) + architectureValue = model->getProperty("architecture"); + model->setProperty("architecture", architectureValue); + result.models.add(modelValue); + } + + const auto reportedPage = readTone3000PaginationInteger( + payloadObject, "page", "current_page"); + const auto reportedTotalPages = + readTone3000PaginationInteger( + payloadObject, "total_pages", "totalPages"); + const auto reportedTotalModels = + readTone3000PaginationInteger( + payloadObject, "total", "total_count"); + const auto reportedNextPage = + readTone3000PaginationInteger( + payloadObject, "next_page", "nextPage"); + const auto reportedHasMore = + readTone3000PaginationBoolean( + payloadObject, "has_more", "hasMore"); + if (! reportedPage.valid + || ! reportedTotalPages.valid + || ! reportedTotalModels.valid + || ! reportedNextPage.valid + || ! reportedHasMore.valid) + { + result.error = makeTone3000Error( + "TONE3000 model pagination returned invalid metadata"); + return result; + } + if (reportedPage.value > 0 && reportedPage.value != page) + { + result.error = makeTone3000Error( + "TONE3000 model pagination returned page " + + juce::String(reportedPage.value) + + " while page " + juce::String(page) + + " was requested"); + return result; + } + + int totalPages = reportedTotalPages.value; + if (totalPages <= 0) + { + if (reportedTotalModels.value > 0) + { + totalPages = static_cast(( + static_cast(reportedTotalModels.value) + + pageSize - 1) / pageSize); + } + } + if (totalPages > kTone3000MaximumModelPages) + { + result.error = makeTone3000Error( + "TONE3000 returned " + juce::String(totalPages) + + " model pages for one tone; OpenStudio's safety limit is " + + juce::String(kTone3000MaximumModelPages) + + ". No partial capture list was published."); + return result; + } + if (totalPages > 0 && page > totalPages) + { + result.error = makeTone3000Error( + "TONE3000 model pagination metadata moved backwards"); + return result; + } + + const bool hasExplicitMore = reportedHasMore.present; + const bool explicitMore = reportedHasMore.value; + const int nextPage = reportedNextPage.value; + + bool shouldContinue = false; + if (totalPages > 0) + shouldContinue = page < totalPages; + else if (hasExplicitMore) + shouldContinue = explicitMore; + else if (nextPage > 0) + shouldContinue = nextPage > page; + else + shouldContinue = models->size() >= pageSize; + + if (hasExplicitMore + && totalPages > 0 + && explicitMore != (page < totalPages)) + { + result.error = makeTone3000Error( + "TONE3000 model pagination returned contradictory metadata"); + return result; + } + if (nextPage > 0 + && shouldContinue + && nextPage != page + 1) + { + result.error = makeTone3000Error( + "TONE3000 model pagination returned an invalid next page"); + return result; + } + if (! shouldContinue) + return result; + if (page == kTone3000MaximumModelPages) + { + result.error = makeTone3000Error( + "TONE3000 model pagination exceeded OpenStudio's bounded safety limit. No partial capture list was published."); + return result; + } + } + + result.error = makeTone3000Error( + "TONE3000 model pagination did not terminate"); + return result; +} + +juce::var fetchTone3000ModelsForTone(int toneId, + const juce::StringArray& architectures, + const juce::String& accessToken, + int modelPageSize, + juce::Array& errors) +{ + juce::Array models; + juce::Array seenModelIds; + + for (const auto& architecture : architectures) + { + const auto pageResult = fetchTone3000ModelPages( + toneId, + architecture, + modelPageSize, + [&] (int page, int pageSize) + { + auto url = juce::URL( + "https://www.tone3000.com/api/v1/models") + .withParameter("tone_id", juce::String(toneId)) + .withParameter("page", juce::String(page)) + .withParameter("page_size", juce::String(pageSize)); + if (architecture.isNotEmpty()) + url = url.withParameter( + "architecture", architecture); + return getTone3000Json( + url, accessToken, "TONE3000 model list"); + }); + if (! pageResult.error.isVoid()) + { + errors.add(pageResult.error); + continue; + } + + for (const auto& modelValue : pageResult.models) + { + const int modelId = static_cast( + modelValue.getProperty("id", 0)); + if (modelId > 0 && seenModelIds.contains(modelId)) + continue; + if (modelId > 0) + seenModelIds.add(modelId); + models.add(modelValue); + } + } + + return juce::var(models); +} + +juce::var searchTone3000NAM(juce::var optionsPayload) +{ + if (optionsPayload.isString()) + optionsPayload = juce::JSON::parse(optionsPayload.toString()); + + auto* options = optionsPayload.getDynamicObject(); + const auto accessToken = getStoredTone3000AccessToken(); + if (accessToken.isEmpty()) + return makeTone3000Error("Connect TONE3000 or refresh the stored token before live search"); + + const auto query = options != nullptr ? getModelObjectString(options, "query") : juce::String(); + const auto sort = normaliseTone3000Sort(options != nullptr ? getModelObjectString(options, "sort") : juce::String()); + const auto gears = normaliseTone3000Gears(options != nullptr ? getModelObjectString(options, "gears") : juce::String("amp_amp-cab")); + auto format = options != nullptr ? getModelObjectString(options, "format") : juce::String(); + if (format.isEmpty() && options != nullptr) + format = getModelObjectString(options, "platform"); + format = format.trim().toLowerCase(); + if (format.isEmpty()) + format = "nam"; + const auto architecture = options != nullptr ? getModelObjectString(options, "architecture") : juce::String(); + const int page = juce::jmax(1, options != nullptr ? getModelObjectInt(options, "page") : 1); + const int pageSize = juce::jlimit(1, 25, options != nullptr ? getModelObjectInt(options, "page_size", "pageSize") : 25); + const int modelPageSize = juce::jlimit(1, 100, options != nullptr ? getModelObjectInt(options, "model_page_size", "modelPageSize") : 20); + const bool includeModels = options == nullptr || !options->hasProperty("includeModels") || static_cast(options->getProperty("includeModels")); + auto architectures = makeTone3000ArchitectureRequests(architecture); + if (format == "ir") + { + architectures.clear(); + architectures.add(juce::String()); + } + + juce::Array tones; + juce::Array errors; + juce::Array seenToneIds; + int total = 0; + int totalPages = 1; + + for (const auto& architectureRequest : architectures) + { + auto url = juce::URL("https://www.tone3000.com/api/v1/tones/search") + .withParameter("query", query) + .withParameter("page", juce::String(page)) + .withParameter("page_size", juce::String(pageSize)) + .withParameter("sort", sort) + .withParameter("format", format); + if (gears.isNotEmpty()) + url = url.withParameter("gears", gears); + if (architectureRequest.isNotEmpty()) + url = url.withParameter("architecture", architectureRequest); + + auto payload = getTone3000Json(url, accessToken, "TONE3000 tone search"); + if (isTone3000ErrorPayload(payload)) + return payload; + + if (auto* payloadObject = payload.getDynamicObject()) + { + total += static_cast(payloadObject->getProperty("total")); + totalPages = juce::jmax(totalPages, static_cast(payloadObject->getProperty("total_pages"))); + const auto data = payloadObject->getProperty("data"); + if (auto* array = data.getArray()) + { + for (auto toneVar : *array) + { + if (auto* tone = toneVar.getDynamicObject()) + { + const int toneId = getModelObjectInt(tone, "id"); + if (toneId > 0 && seenToneIds.contains(toneId)) + continue; + + if (toneId > 0) + seenToneIds.add(toneId); + + tone->setProperty("source", "tone3000-live"); + tone->setProperty("sortBucket", sort); + tone->setProperty("searchArchitecture", architectureRequest); + tones.add(toneVar); + } + } + } + } + } + + if (includeModels) + { + for (auto& toneVar : tones) + { + if (auto* tone = toneVar.getDynamicObject()) + { + const int toneId = getModelObjectInt(tone, "id"); + if (toneId > 0) + tone->setProperty("models", fetchTone3000ModelsForTone(toneId, architectures, accessToken, modelPageSize, errors)); + } + } + } + + auto result = makeTone3000Success(); + if (auto* object = result.getDynamicObject()) + { + object->setProperty("data", tones); + object->setProperty("tones", tones); + object->setProperty("errors", errors); + object->setProperty("page", page); + object->setProperty("page_size", pageSize); + object->setProperty("pageSize", pageSize); + object->setProperty("total", total); + object->setProperty("total_pages", totalPages); + object->setProperty("totalPages", totalPages); + object->setProperty("has_more", page < totalPages); + object->setProperty("hasMore", page < totalPages); + object->setProperty("next_page", page < totalPages ? juce::var(page + 1) : juce::var()); + object->setProperty("nextPage", page < totalPages ? juce::var(page + 1) : juce::var()); + object->setProperty("query", query); + object->setProperty("sort", sort); + object->setProperty("gears", gears); + object->setProperty("format", format); + object->setProperty("architecture", architecture.isNotEmpty() ? architecture : juce::String("all")); + object->setProperty("source", "tone3000-live"); + object->setProperty("generatedAt", juce::Time::getCurrentTime().toISO8601(true)); + object->setProperty("rateLimit", "100 requests per minute default; search manually and avoid bulk refreshes"); + } + return result; +} + +juce::var runTone3000AuthenticatedQA() +{ + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + result->setProperty("provider", "tone3000.com"); + result->setProperty("checkedAt", juce::Time::getCurrentTime().toISO8601(true)); + + const auto authStatus = makeTone3000AuthStatus(); + auto* authObject = authStatus.getDynamicObject(); + const bool qaReady = authObject != nullptr + && static_cast(authObject->getProperty("authenticatedQAReady")); + result->setProperty("authenticatedQAReady", qaReady); + result->setProperty("integrationState", authObject != nullptr + ? authObject->getProperty("integrationState") + : juce::var("status_unavailable")); + if (! qaReady) + { + result->setProperty("status", "not_run"); + result->setProperty("error", "Connect a non-expired TONE3000 account before running authenticated integration QA."); + return juce::var(result.get()); + } + + juce::DynamicObject::Ptr options = new juce::DynamicObject(); + options->setProperty("query", "amp"); + options->setProperty("page", 1); + options->setProperty("page_size", 1); + options->setProperty("architecture", "1"); + options->setProperty("gears", ""); + options->setProperty("includeModels", false); + const auto probe = searchTone3000NAM(juce::var(options.get())); + auto* probeObject = probe.getDynamicObject(); + const bool success = probeObject != nullptr + && static_cast(probeObject->getProperty("success")); + result->setProperty("success", success); + result->setProperty("status", success ? "pass" : "fail"); + if (probeObject != nullptr) + { + result->setProperty("statusCode", probeObject->getProperty("statusCode")); + result->setProperty("error", probeObject->getProperty("error")); + const auto tonesVar = probeObject->getProperty("tones"); + result->setProperty("returnedToneCount", tonesVar.isArray() ? tonesVar.getArray()->size() : 0); + } + return juce::var(result.get()); +} + +juce::var getTone3000ToneDetail(int toneId, const juce::String& architecture) +{ + const auto accessToken = getStoredTone3000AccessToken(); + if (accessToken.isEmpty()) + return makeTone3000Error("Connect TONE3000 or refresh the stored token before loading tone detail"); + if (toneId <= 0) + return makeTone3000Error("Missing TONE3000 tone ID"); + + auto architectures = makeTone3000ArchitectureRequests(architecture); + if (architecture.trim().isEmpty()) + { + architectures.clear(); + architectures.add(juce::String()); + } + auto url = juce::URL("https://www.tone3000.com/api/v1/tones/" + juce::String(toneId)); + if (architectures.size() == 1 && architectures[0].isNotEmpty()) + url = url.withParameter("architecture", architectures[0]); + + auto tone = getTone3000Json(url, accessToken, "TONE3000 tone detail"); + if (isTone3000ErrorPayload(tone)) + return tone; + + juce::Array errors; + const auto models = fetchTone3000ModelsForTone( + toneId, architectures, accessToken, 100, errors); + if (! errors.isEmpty()) + { + auto failure = errors.getFirst(); + if (auto* failureObject = failure.getDynamicObject()) + { + failureObject->setProperty("tone", tone); + failureObject->setProperty("errors", errors); + } + return failure; + } + + auto result = makeTone3000Success(); + if (auto* object = result.getDynamicObject()) + { + object->setProperty("tone", tone); + object->setProperty("models", models); + object->setProperty("errors", errors); + } + return result; +} + +juce::var createTone3000AuthRequest(const juce::String& clientId, + const juce::String& redirectUri, + const juce::String& prompt, + const juce::String& toneId, + const juce::String& loginHint) +{ + if (clientId.trim().isEmpty()) + return makeTone3000Error("Missing TONE3000 client_id"); + if (redirectUri.trim().isEmpty()) + return makeTone3000Error("Missing OAuth redirect_uri"); + + const auto credentialEpoch = beginTone3000CredentialEpoch(); + const auto verifier = makeTone3000CodeVerifier(); + const auto challenge = makeTone3000CodeChallenge(verifier); + const auto state = juce::Uuid().toString(); + + juce::URL authUrl("https://www.tone3000.com/api/v1/oauth/authorize"); + authUrl = authUrl.withParameter("client_id", clientId.trim()) + .withParameter("redirect_uri", redirectUri.trim()) + .withParameter("response_type", "code") + .withParameter("code_challenge", challenge) + .withParameter("code_challenge_method", "S256") + .withParameter("state", state) + .withParameter("platform", "nam") + .withParameter("gears", "amp_amp-cab") + .withParameter("architecture", "2") + .withParameter("menubar", "true"); + if (prompt.isNotEmpty()) + authUrl = authUrl.withParameter("prompt", prompt); + if (toneId.isNotEmpty()) + authUrl = authUrl.withParameter("tone_id", toneId); + if (loginHint.isNotEmpty()) + authUrl = authUrl.withParameter("login_hint", loginHint); + + juce::DynamicObject::Ptr pending = new juce::DynamicObject(); + pending->setProperty("schemaVersion", 1); + pending->setProperty( + "credentialEpoch", + static_cast(credentialEpoch)); + pending->setProperty( + "credentialSession", tone3000CredentialSessionId); + pending->setProperty( + "credentialRevision", juce::Uuid().toString()); + pending->setProperty("clientId", clientId.trim()); + pending->setProperty("redirectUri", redirectUri.trim()); + pending->setProperty("codeVerifier", verifier); + pending->setProperty("state", state); + pending->setProperty("createdAtMs", static_cast(juce::Time::getCurrentTime().toMilliseconds())); + pending->setProperty("createdAt", juce::Time::getCurrentTime().toISO8601(true)); + + juce::String error; + { + const std::lock_guard storageGuard( + tone3000PendingAuthStorageMutex); + if (isTone3000TaskCancelled()) + { + return makeTone3000Error( + "The TONE3000 sign-in request was canceled."); + } + if (tone3000CredentialEpoch.load( + std::memory_order_acquire) != credentialEpoch) + { + return makeTone3000Error( + "This TONE3000 sign-in request was superseded by a newer credential action."); + } + const ScopedTone3000CredentialProcessLock processGuard; + if (! processGuard.locked) + { + return makeTone3000Error( + "Timed out waiting for another OpenStudio instance to finish updating TONE3000 credentials."); + } + juce::String existingError; + const auto existingPending = loadProtectedJson( + getTone3000PendingAuthFile(), existingError); + if (tone3000PendingRecordOwnedByLiveOtherSession( + existingPending.getDynamicObject(), + juce::Time::getCurrentTime().toMilliseconds())) + { + return makeTone3000Error( + "Another OpenStudio instance already has a TONE3000 sign-in in progress."); + } + if (existingPending.isVoid() && existingError.isNotEmpty()) + return makeTone3000Error(existingError); + if (! saveProtectedJson( + getTone3000PendingAuthFile(), + juce::var(pending.get()), + error)) + { + return makeTone3000Error(error); + } + } + + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", true); + result->setProperty("authUrl", authUrl.toString(true)); + result->setProperty("state", state); + result->setProperty("redirectUri", redirectUri.trim()); + result->setProperty("clientId", clientId.trim()); + result->setProperty( + "credentialEpoch", + static_cast(credentialEpoch)); + return juce::var(result.get()); +} + +bool deleteTone3000PendingAuthIfCurrent( + const juce::String& expectedState, + juce::uint64 expectedEpoch) +{ + const std::lock_guard storageGuard( + tone3000PendingAuthStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (! processGuard.locked) + return false; + juce::String error; + const auto currentPending = loadProtectedJson( + getTone3000PendingAuthFile(), error); + const auto* currentObject = currentPending.getDynamicObject(); + if (currentObject == nullptr) + return error.isEmpty(); + + const auto currentRecordEpoch = + getTone3000RecordEpoch(currentObject); + const auto currentRecordSession = + getTone3000RecordSession(currentObject); + const auto currentState = + currentObject->getProperty("state").toString(); + if (! tone3000CredentialSnapshotStillCurrent( + expectedEpoch, + expectedState, + tone3000CredentialEpoch.load( + std::memory_order_acquire), + currentState) + || (currentRecordSession == tone3000CredentialSessionId + && currentRecordEpoch > 0 + && currentRecordEpoch != expectedEpoch)) + { + // A newer request owns the pending record. Preserving it is a + // successful cleanup outcome for this older flow. + return true; + } + return deleteProtectedText(getTone3000PendingAuthFile()); +} + +bool tone3000PendingAuthStillCurrent( + const juce::String& expectedState, + juce::uint64 expectedEpoch) +{ + const std::lock_guard storageGuard( + tone3000PendingAuthStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (! processGuard.locked) + return false; + + juce::String error; + const auto pending = loadProtectedJson( + getTone3000PendingAuthFile(), error); + const auto* pendingObject = pending.getDynamicObject(); + if (pendingObject == nullptr || error.isNotEmpty()) + return false; + + return getTone3000RecordSession(pendingObject) + == tone3000CredentialSessionId + && getTone3000RecordEpoch(pendingObject) == expectedEpoch + && pendingObject->getProperty("state").toString() + == expectedState + && tone3000CredentialEpoch.load(std::memory_order_acquire) + == expectedEpoch; +} + +juce::var exchangeTone3000OAuthCode(const juce::String& code, + const juce::String& stateFromCallback, + const juce::String& clientIdOverride, + const juce::String& redirectUriOverride) +{ + if (code.trim().isEmpty()) + return makeTone3000Error("Missing OAuth code"); + + juce::String error; + juce::var pending; + { + const std::lock_guard storageGuard( + tone3000PendingAuthStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (processGuard.locked) + { + pending = loadProtectedJson( + getTone3000PendingAuthFile(), error); + } + else + { + error = "Timed out waiting for another OpenStudio instance to finish updating TONE3000 credentials."; + } + } + auto* pendingObject = pending.getDynamicObject(); + if (pendingObject == nullptr) + return makeTone3000Error(error.isNotEmpty() ? error : "No pending TONE3000 OAuth request"); + + const auto currentEpoch = tone3000CredentialEpoch.load( + std::memory_order_acquire); + const auto storedEpoch = getTone3000RecordEpoch(pendingObject); + const auto storedSession = + getTone3000RecordSession(pendingObject); + const auto expectedEpoch = + storedSession == tone3000CredentialSessionId + && storedEpoch > 0 + ? storedEpoch + : currentEpoch; + const auto expectedState = + pendingObject->getProperty("state").toString(); + if (expectedEpoch != currentEpoch) + { + return makeTone3000Error( + "This TONE3000 sign-in request was superseded by a newer credential action."); + } + + const auto createdAtMs = static_cast( + static_cast(pendingObject->getProperty("createdAtMs"))); + const auto requestAgeMs = juce::Time::getCurrentTime().toMilliseconds() - createdAtMs; + if (createdAtMs <= 0 || requestAgeMs < 0 || requestAgeMs > kTone3000LoopbackTimeoutMs) + { + const std::lock_guard storageGuard( + tone3000PendingAuthStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (! processGuard.locked) + { + return makeTone3000Error( + "Timed out waiting for another OpenStudio instance to finish updating TONE3000 credentials."); + } + juce::String currentError; + const auto currentPending = loadProtectedJson( + getTone3000PendingAuthFile(), currentError); + const auto* currentPendingObject = + currentPending.getDynamicObject(); + const auto currentPendingEpoch = + getTone3000RecordEpoch(currentPendingObject); + const auto currentPendingSession = + getTone3000RecordSession(currentPendingObject); + if (tone3000CredentialSnapshotStillCurrent( + expectedEpoch, + expectedState, + tone3000CredentialEpoch.load( + std::memory_order_acquire), + currentPendingObject != nullptr + ? currentPendingObject->getProperty( + "state").toString() + : juce::String()) + && (currentPendingSession != tone3000CredentialSessionId + || currentPendingEpoch == 0 + || currentPendingEpoch == expectedEpoch)) + { + (void) deleteProtectedText( + getTone3000PendingAuthFile()); + } + return makeTone3000Error("The pending TONE3000 sign-in expired. Start the connection again."); + } + + if (stateFromCallback.isEmpty()) + return makeTone3000Error("OAuth callback did not include the required state"); + if (expectedState != stateFromCallback) + return makeTone3000Error("OAuth state mismatch"); + + const auto clientId = clientIdOverride.trim().isNotEmpty() + ? clientIdOverride.trim() + : pendingObject->getProperty("clientId").toString(); + const auto redirectUri = redirectUriOverride.trim().isNotEmpty() + ? redirectUriOverride.trim() + : pendingObject->getProperty("redirectUri").toString(); + const auto verifier = pendingObject->getProperty("codeVerifier").toString(); + + juce::StringPairArray fields; + fields.set("grant_type", "authorization_code"); + fields.set("code", code.trim()); + fields.set("code_verifier", verifier); + fields.set("redirect_uri", redirectUri); + fields.set("client_id", clientId); + + auto tokenPayload = postTone3000OAuthToken(fields); + if (isTone3000TaskCancelled()) + { + return makeTone3000Error( + "The TONE3000 token exchange was canceled before credentials were stored."); + } + if (auto* tokenObject = tokenPayload.getDynamicObject()) + { + if (static_cast(tokenObject->getProperty("success")) == false && tokenObject->hasProperty("error")) + return tokenPayload; + } + + std::scoped_lock storageGuards( + tone3000TokenStorageMutex, + tone3000PendingAuthStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (! processGuard.locked) + { + return makeTone3000Error( + "Timed out waiting for another OpenStudio instance to finish updating TONE3000 credentials."); + } + juce::String currentPendingError; + const auto currentPending = loadProtectedJson( + getTone3000PendingAuthFile(), currentPendingError); + const auto* currentPendingObject = + currentPending.getDynamicObject(); + const auto currentPendingEpoch = + getTone3000RecordEpoch(currentPendingObject); + const auto currentPendingSession = + getTone3000RecordSession(currentPendingObject); + if (! tone3000CredentialSnapshotStillCurrent( + expectedEpoch, + expectedState, + tone3000CredentialEpoch.load( + std::memory_order_acquire), + currentPendingObject != nullptr + ? currentPendingObject->getProperty("state").toString() + : juce::String()) + || (currentPendingSession == tone3000CredentialSessionId + && currentPendingEpoch > 0 + && currentPendingEpoch != expectedEpoch)) + { + return makeTone3000Error( + "This TONE3000 token exchange was superseded before credentials could be stored."); + } + + auto result = storeTone3000TokenPayloadUnlocked( + tokenPayload, clientId, expectedEpoch); + if (auto* resultObject = result.getDynamicObject(); + resultObject != nullptr + && static_cast(resultObject->getProperty("success"))) + { + const bool pendingDeleted = + deleteProtectedText(getTone3000PendingAuthFile()); + resultObject->setProperty( + "pendingAuthDeleted", pendingDeleted); + if (! pendingDeleted) + { + resultObject->setProperty( + "warning", + "TONE3000 connected, but OpenStudio could not clear the completed pending secure session."); + } + } + return result; +} + +juce::var waitForTone3000LoopbackCallback(juce::StreamingSocket& listener, + int generation, + int timeoutMs, + const juce::String& expectedState, + juce::uint64 expectedCredentialEpoch) +{ + const auto deadline = juce::Time::currentTimeMillis() + timeoutMs; + + while (juce::Time::currentTimeMillis() < deadline) + { + if (isTone3000TaskCancelled()) + { + return makeTone3000AuthFlowResult( + "canceled", + false, + "The OpenStudio window that started TONE3000 sign-in was closed."); + } + if (generation != tone3000AuthFlowGeneration.load()) + { + return makeTone3000AuthFlowResult("canceled", false, "TONE3000 sign-in was canceled."); + } + + const auto ready = listener.waitUntilReady(true, 250); + if (ready < 0) + return makeTone3000AuthFlowResult("failed", false, "TONE3000 callback listener stopped unexpectedly.", {}, {}, true); + if (ready == 0) + continue; + + std::unique_ptr client(listener.waitForNextConnection()); + if (client == nullptr) + continue; + + const auto request = readTone3000HttpRequest(*client, 3000); + if (! tone3000PendingAuthStillCurrent( + expectedState, expectedCredentialEpoch)) + { + writeTone3000CallbackPage( + *client, + "TONE3000 sign-in canceled", + "The secure TONE3000 sign-in session was cleared in OpenStudio.", + false); + return makeTone3000AuthFlowResult( + "canceled", + false, + "TONE3000 sign-in was canceled because its secure session was cleared."); + } + const auto firstLine = request.upToFirstOccurrenceOf("\n", false, false).trim(); + if (! firstLine.startsWith("GET ")) + { + writeTone3000CallbackPage(*client, "OpenStudio TONE3000", "This callback only accepts a browser GET request.", false); + continue; + } + + const auto target = firstLine.fromFirstOccurrenceOf(" ", false, false) + .upToFirstOccurrenceOf(" ", false, false) + .trim(); + const auto path = target.upToFirstOccurrenceOf("?", false, false); + if (path != kTone3000LoopbackPath) + { + writeTone3000CallbackPage(*client, "OpenStudio TONE3000", "This callback path is not used by OpenStudio.", false); + continue; + } + + const auto query = target.fromFirstOccurrenceOf("?", false, false); + const auto params = parseTone3000QueryString(query); + const auto error = params["error"]; + const auto canceled = params["canceled"]; + const auto toneId = params["tone_id"]; + const auto code = params["code"]; + const auto state = params["state"]; + + if (error.equalsIgnoreCase("access_denied") + || (canceled.equalsIgnoreCase("true") && code.isEmpty())) + { + (void) deleteTone3000PendingAuthIfCurrent( + expectedState, expectedCredentialEpoch); + writeTone3000CallbackPage(*client, "TONE3000 sign-in canceled", "You can return to OpenStudio and connect again when you are ready.", false); + return makeTone3000AuthFlowResult("canceled", false, error.isNotEmpty() ? error : juce::String("TONE3000 sign-in was canceled."), {}, toneId); + } + + if (error.isNotEmpty()) + { + (void) deleteTone3000PendingAuthIfCurrent( + expectedState, expectedCredentialEpoch); + writeTone3000CallbackPage(*client, "TONE3000 sign-in failed", "OpenStudio received an authorization error. Return to the app and try again.", false); + return makeTone3000AuthFlowResult("failed", false, error, {}, toneId); + } + + if (code.isEmpty()) + { + (void) deleteTone3000PendingAuthIfCurrent( + expectedState, expectedCredentialEpoch); + writeTone3000CallbackPage(*client, "TONE3000 sign-in failed", "OpenStudio did not receive an OAuth code from TONE3000.", false); + return makeTone3000AuthFlowResult("failed", false, "OAuth callback did not include a code.", {}, toneId); + } + if (state.isEmpty()) + { + (void) deleteTone3000PendingAuthIfCurrent( + expectedState, expectedCredentialEpoch); + writeTone3000CallbackPage(*client, "TONE3000 sign-in failed", "OpenStudio did not receive the OAuth security state from TONE3000.", false); + return makeTone3000AuthFlowResult("failed", false, "OAuth callback did not include the required state.", {}, toneId); + } + + auto exchangeResult = exchangeTone3000OAuthCode(code, state, {}, {}); + if (auto* exchangeObject = exchangeResult.getDynamicObject()) + { + if (static_cast (exchangeObject->getProperty("success"))) + { + exchangeObject->setProperty("status", "connected"); + if (toneId.isNotEmpty()) + exchangeObject->setProperty("toneId", toneId); + writeTone3000CallbackPage(*client, "Connected to OpenStudio", "You can return to OpenStudio. TONE3000 is connected for this user account.", true); + return exchangeResult; + } + + writeTone3000CallbackPage(*client, "TONE3000 sign-in failed", "OpenStudio could not complete the token exchange. Return to the app and try again.", false); + exchangeObject->setProperty("status", "failed"); + return exchangeResult; + } + + writeTone3000CallbackPage(*client, "TONE3000 sign-in failed", "OpenStudio could not read the token exchange result.", false); + return makeTone3000AuthFlowResult("failed", false, "Token exchange returned an invalid result.", {}, toneId); + } + + (void) deleteTone3000PendingAuthIfCurrent( + expectedState, expectedCredentialEpoch); + return makeTone3000AuthFlowResult("failed", false, "Timed out waiting for TONE3000 to return to OpenStudio.", {}, {}, true); +} + +juce::var startTone3000AuthFlow(juce::var optionsPayload) +{ + if (optionsPayload.isString()) + optionsPayload = juce::JSON::parse(optionsPayload.toString()); + + auto* options = optionsPayload.getDynamicObject(); + auto clientId = getTone3000OptionString(options, "clientId", getConfiguredTone3000ClientId()); + const auto redirectUri = getTone3000OptionString(options, "redirectUri", kTone3000DefaultRedirectUri); + const auto prompt = getTone3000OptionString(options, "prompt"); + const auto toneId = getTone3000OptionString(options, "toneId"); + const auto loginHint = getTone3000OptionString(options, "loginHint"); + const auto timeoutMs = juce::jlimit(30000, kTone3000LoopbackTimeoutMs, getTone3000OptionInt(options, "timeoutMs", kTone3000LoopbackTimeoutMs)); + + if (clientId.isEmpty()) + { + return makeTone3000AuthFlowResult("failed", + false, + "OpenStudio was not built with a TONE3000 publishable client_id. Use Advanced / Developer with a registered test client_id.", + {}, + {}, + true); + } + + const ScopedTone3000AuthFlowProcessLock processFlowGuard; + if (! processFlowGuard.locked) + { + return makeTone3000AuthFlowResult( + "failed", + false, + "Another OpenStudio instance already has a TONE3000 sign-in in progress."); + } + + if (tone3000AuthFlowActive.exchange(true)) + return makeTone3000AuthFlowResult("failed", false, "A TONE3000 sign-in is already in progress."); + + struct ActiveFlag + { + ~ActiveFlag() { tone3000AuthFlowActive.store(false); } + } activeFlag; + + const auto generation = tone3000AuthFlowGeneration.fetch_add(1) + 1; + + juce::StreamingSocket listener; + if (! listener.createListener(kTone3000LoopbackPort, kTone3000LoopbackHost)) + { + auto fallbackRequest = createTone3000AuthRequest(clientId, redirectUri, prompt, toneId, loginHint); + juce::String authUrl; + if (auto* fallbackObject = fallbackRequest.getDynamicObject()) + authUrl = fallbackObject->getProperty("authUrl").toString(); + + return makeTone3000AuthFlowResult("failed", + false, + "Could not open the local TONE3000 callback listener on 127.0.0.1:18762. Use Advanced / Developer fallback.", + authUrl, + {}, + true); + } + + auto authRequest = createTone3000AuthRequest(clientId, redirectUri, prompt, toneId, loginHint); + auto* requestObject = authRequest.getDynamicObject(); + if (requestObject == nullptr || ! static_cast (requestObject->getProperty("success"))) + { + const auto error = requestObject != nullptr ? requestObject->getProperty("error").toString() : juce::String("Could not create TONE3000 auth request."); + return makeTone3000AuthFlowResult("failed", false, error, {}, {}, true); + } + + const auto authUrl = requestObject->getProperty("authUrl").toString(); + const auto expectedState = + requestObject->getProperty("state").toString(); + const auto expectedCredentialEpoch = + static_cast(static_cast( + requestObject->getProperty("credentialEpoch"))); + if (authUrl.isEmpty()) + return makeTone3000AuthFlowResult("failed", false, "TONE3000 did not produce an authorization URL.", {}, {}, true); + + if (! juce::URL(authUrl).launchInDefaultBrowser()) + return makeTone3000AuthFlowResult("failed", false, "Could not open the TONE3000 sign-in page in the default browser.", authUrl, {}, true); + + auto result = waitForTone3000LoopbackCallback( + listener, + generation, + timeoutMs, + expectedState, + expectedCredentialEpoch); + if (auto* resultObject = result.getDynamicObject()) + { + if (! resultObject->hasProperty("authUrl")) + resultObject->setProperty("authUrl", authUrl); + if (! resultObject->hasProperty("clientId")) + resultObject->setProperty("clientId", clientId); + } + return result; +} + +juce::var cancelTone3000AuthFlow() +{ + tone3000AuthFlowGeneration.fetch_add(1); + const auto cancelEpoch = beginTone3000CredentialEpoch(); + + // Wake the loopback listener before secure-storage cleanup, which may ask + // Secret Service to unlock and therefore take time on Linux. + juce::StreamingSocket wakeSocket; + wakeSocket.connect(kTone3000LoopbackHost, kTone3000LoopbackPort, 500); + + bool newerPendingAuthPreserved = false; + bool pendingAuthDeleted = false; + bool pendingAuthCleanupComplete = false; + { + const std::lock_guard storageGuard( + tone3000PendingAuthStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (processGuard.locked) + { + juce::String loadError; + const auto pending = loadProtectedJson( + getTone3000PendingAuthFile(), loadError); + const auto* pendingObject = pending.getDynamicObject(); + newerPendingAuthPreserved = pendingObject != nullptr + && ! tone3000PendingRecordCanBeInvalidated( + getTone3000RecordSession(pendingObject), + getTone3000RecordEpoch(pendingObject), + cancelEpoch); + pendingAuthDeleted = ! newerPendingAuthPreserved + && deleteProtectedText( + getTone3000PendingAuthFile()); + pendingAuthCleanupComplete = + newerPendingAuthPreserved || pendingAuthDeleted; + } + } + auto result = makeTone3000AuthFlowResult( + "canceled", + pendingAuthCleanupComplete, + pendingAuthCleanupComplete + ? juce::String() + : juce::String( + "TONE3000 sign-in was canceled, but OpenStudio could not clear its pending secure session.")); + if (auto* object = result.getDynamicObject()) + { + object->setProperty("canceled", true); + object->setProperty("pendingAuthDeleted", pendingAuthDeleted); + object->setProperty( + "pendingAuthCleanupComplete", + pendingAuthCleanupComplete); + object->setProperty( + "newerPendingAuthPreserved", + newerPendingAuthPreserved); + } + return result; +} + +juce::var clearTone3000Auth() +{ + tone3000AuthFlowGeneration.fetch_add(1); + const auto clearEpoch = beginTone3000CredentialEpoch(); + bool newerTokenPreserved = false; + bool newerPendingAuthPreserved = false; + bool tokenDeleted = false; + bool pendingAuthDeleted = false; + bool tokenClearComplete = false; + bool pendingAuthClearComplete = false; + bool tokenWasPresent = false; + { + std::scoped_lock storageGuards( + tone3000TokenStorageMutex, + tone3000PendingAuthStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (processGuard.locked) + { + juce::String tokenLoadError; + const auto token = loadProtectedJson( + getTone3000TokenFile(), tokenLoadError); + const auto* tokenObject = token.getDynamicObject(); + tokenWasPresent = tokenObject != nullptr + && makeTone3000TokenIdentity( + tokenObject).isNotEmpty(); + newerTokenPreserved = tokenObject != nullptr + && ! tone3000RecordCanBeInvalidated( + getTone3000RecordSession(tokenObject), + getTone3000RecordEpoch(tokenObject), + clearEpoch); + tokenDeleted = ! newerTokenPreserved + && deleteProtectedText(getTone3000TokenFile()); + tokenClearComplete = + newerTokenPreserved || tokenDeleted; + + juce::String pendingLoadError; + const auto pending = loadProtectedJson( + getTone3000PendingAuthFile(), pendingLoadError); + const auto* pendingObject = pending.getDynamicObject(); + newerPendingAuthPreserved = pendingObject != nullptr + && ! tone3000RecordCanBeInvalidated( + getTone3000RecordSession(pendingObject), + getTone3000RecordEpoch(pendingObject), + clearEpoch); + pendingAuthDeleted = ! newerPendingAuthPreserved + && deleteProtectedText( + getTone3000PendingAuthFile()); + pendingAuthClearComplete = + newerPendingAuthPreserved || pendingAuthDeleted; + } + } + if (pendingAuthDeleted) + { + // A sign-in listener may belong to another OpenStudio instance. Wake + // it after the global clear transaction so it can observe the missing + // secure pending record instead of waiting for the ten-minute timeout. + juce::StreamingSocket wakeSocket; + if (wakeSocket.connect( + kTone3000LoopbackHost, + kTone3000LoopbackPort, + 500)) + { + constexpr auto request = + "GET /tone3000/credential-cleared HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Connection: close\r\n\r\n"; + (void) wakeSocket.write( + request, static_cast(std::strlen(request))); + } + } + if (! tokenClearComplete || ! pendingAuthClearComplete) + { + auto failure = makeTone3000Error( + "OpenStudio could not completely clear the secure TONE3000 session."); + if (auto* object = failure.getDynamicObject()) + { + object->setProperty( + "authenticated", + tokenWasPresent && ! tokenDeleted); + object->setProperty("tokenDeleted", tokenDeleted); + object->setProperty("pendingAuthDeleted", pendingAuthDeleted); + object->setProperty( + "newerTokenPreserved", newerTokenPreserved); + object->setProperty( + "newerPendingAuthPreserved", + newerPendingAuthPreserved); + } + return failure; + } + + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", true); + result->setProperty( + "authenticated", newerTokenPreserved); + result->setProperty("tokenDeleted", tokenDeleted); + result->setProperty( + "pendingAuthDeleted", pendingAuthDeleted); + result->setProperty( + "tokenClearComplete", tokenClearComplete); + result->setProperty( + "pendingAuthClearComplete", pendingAuthClearComplete); + result->setProperty( + "newerTokenPreserved", newerTokenPreserved); + result->setProperty( + "newerPendingAuthPreserved", + newerPendingAuthPreserved); + return juce::var(result.get()); +} + +juce::var refreshTone3000Auth(const juce::String& clientIdOverride) +{ + // OAuth providers may rotate refresh tokens. Only one refresh request may + // consume/publish a token at a time across every OpenStudio instance. + // The dedicated lock spans the network request but is separate from the + // short storage transaction lock, so Clear and status remain responsive. + const ScopedTone3000TokenRefreshLock refreshGuard; + if (! refreshGuard.locked) + { + return makeTone3000Error( + "TONE3000 refresh was canceled or timed out while waiting for another refresh to finish."); + } + + juce::String error; + juce::var stored; + juce::uint64 snapshotEpoch = 0; + { + const std::lock_guard storageGuard( + tone3000TokenStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (processGuard.locked) + { + stored = loadProtectedJson( + getTone3000TokenFile(), error); + snapshotEpoch = tone3000CredentialEpoch.load( + std::memory_order_acquire); + } + else + { + error = "Timed out waiting for another OpenStudio instance to finish updating TONE3000 credentials."; + } + } + auto* object = stored.getDynamicObject(); + if (object == nullptr) + return makeTone3000Error(error.isNotEmpty() ? error : "No stored TONE3000 token"); + + const auto refreshToken = object->getProperty("refreshToken").toString(); + const auto clientId = clientIdOverride.trim().isNotEmpty() + ? clientIdOverride.trim() + : object->getProperty("clientId").toString(); + if (refreshToken.isEmpty()) + return makeTone3000Error("No stored TONE3000 refresh token"); + if (clientId.isEmpty()) + return makeTone3000Error("Missing TONE3000 client_id"); + const auto expectedTokenIdentity = + makeTone3000TokenIdentity(object); + + juce::StringPairArray fields; + fields.set("grant_type", "refresh_token"); + fields.set("refresh_token", refreshToken); + fields.set("client_id", clientId); + + auto tokenPayload = postTone3000OAuthToken(fields); + if (isTone3000TaskCancelled()) + { + return makeTone3000Error( + "The TONE3000 refresh was canceled before credentials were updated."); + } + if (auto* tokenObject = tokenPayload.getDynamicObject()) + { + if (static_cast(tokenObject->getProperty("success")) == false && tokenObject->hasProperty("error")) + { + if (tokenObject->getProperty("oauthError").toString() == "invalid_grant") + { + bool invalidatedCurrentToken = false; + bool currentTokenPresent = false; + bool snapshotStillCurrent = false; + { + const std::lock_guard storageGuard( + tone3000TokenStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (processGuard.locked) + { + juce::String currentError; + const auto currentStored = loadProtectedJson( + getTone3000TokenFile(), currentError); + const auto* currentObject = + currentStored.getDynamicObject(); + currentTokenPresent = + makeTone3000TokenIdentity( + currentObject).isNotEmpty(); + snapshotStillCurrent = + tone3000CredentialSnapshotStillCurrent( + snapshotEpoch, + expectedTokenIdentity, + tone3000CredentialEpoch.load( + std::memory_order_acquire), + makeTone3000TokenIdentity( + currentObject)); + if (snapshotStillCurrent) + { + invalidatedCurrentToken = + deleteProtectedText( + getTone3000TokenFile()); + } + } + } + auto expired = makeTone3000Error("TONE3000 session expired. Connect again.", static_cast (tokenObject->getProperty("statusCode"))); + if (auto* expiredObject = expired.getDynamicObject()) + { + expiredObject->setProperty("oauthError", "invalid_grant"); + expiredObject->setProperty( + "authenticated", + currentTokenPresent + && ! invalidatedCurrentToken); + expiredObject->setProperty( + "tokenInvalidated", invalidatedCurrentToken); + if (! snapshotStillCurrent) + { + expiredObject->setProperty( + "superseded", true); + } + } + return expired; + } + return tokenPayload; + } + if (!tokenObject->hasProperty("refresh_token") || tokenObject->getProperty("refresh_token").toString().isEmpty()) + tokenObject->setProperty("refresh_token", refreshToken); + } + + const std::lock_guard storageGuard( + tone3000TokenStorageMutex); + const ScopedTone3000CredentialProcessLock processGuard; + if (! processGuard.locked) + { + return makeTone3000Error( + "Timed out waiting for another OpenStudio instance to finish updating TONE3000 credentials."); + } + juce::String currentError; + const auto currentStored = loadProtectedJson( + getTone3000TokenFile(), currentError); + const auto* currentObject = currentStored.getDynamicObject(); + if (! tone3000CredentialSnapshotStillCurrent( + snapshotEpoch, + expectedTokenIdentity, + tone3000CredentialEpoch.load( + std::memory_order_acquire), + makeTone3000TokenIdentity(currentObject))) + { + return makeTone3000Error( + "This TONE3000 refresh was superseded before credentials could be stored."); + } + return storeTone3000TokenPayloadUnlocked( + tokenPayload, clientId, snapshotEpoch); +} + +juce::String sanitizeNAMFileName(juce::String name) +{ + name = name.trim(); + if (name.isEmpty()) + name = "nam-model"; + + juce::String safe; + for (int i = 0; i < name.length(); ++i) + { + const auto c = name[i]; + const bool ok = juce::CharacterFunctions::isLetterOrDigit(c) || c == '-' || c == '_' || c == '.'; + safe << (ok ? juce::String::charToString(c) : "-"); + } + while (safe.contains("--")) + safe = safe.replace("--", "-"); + return safe.trimCharactersAtStart("-").trimCharactersAtEnd("-").substring(0, 96); +} + +bool isTone3000AudioIRExtension(const juce::String& extension) +{ + const auto ext = extension.toLowerCase(); + return ext == ".wav" || ext == ".wave" || ext == ".aif" || ext == ".aiff" || ext == ".flac"; +} + +juce::String getTone3000DownloadExtension(const juce::String& modelUrl) +{ + const auto withoutQuery = modelUrl.upToFirstOccurrenceOf("?", false, false); + const auto withoutFragment = withoutQuery.upToFirstOccurrenceOf("#", false, false); + return juce::File(juce::URL::removeEscapeChars(withoutFragment)).getFileExtension().toLowerCase(); +} + +juce::var parseJsonFileOrDefault(const juce::File& file, const juce::String& arrayProperty) +{ + if (file.existsAsFile()) + { + auto parsed = juce::JSON::parse(file); + if (!parsed.isVoid()) + return parsed; + } + + juce::DynamicObject::Ptr root = new juce::DynamicObject(); + root->setProperty("schemaVersion", 1); + root->setProperty(arrayProperty, juce::Array()); + return juce::var(root.get()); +} + +juce::String normaliseNAMCatalogUpdaterArchitecture(juce::String architecture) +{ + architecture = architecture.trim().toLowerCase(); + if (architecture == "a1") + return "1"; + if (architecture == "a2") + return "2"; + if (architecture == "1" || architecture == "2" || architecture == "custom") + return architecture; + return {}; +} + +std::string makeNAMCatalogModelCacheKey(int toneId, + const juce::String& architecture) +{ + return (juce::String(toneId) + ":" + + normaliseNAMCatalogUpdaterArchitecture(architecture)) + .toStdString(); +} + +juce::var sanitizeNAMCatalogModels(const juce::var& modelsValue, + int expectedToneId, + const juce::String& expectedArchitecture) +{ + juce::Array accepted; + const auto expected = + normaliseNAMCatalogUpdaterArchitecture(expectedArchitecture); + if (auto* models = modelsValue.getArray()) + { + for (const auto& modelValue : *models) + { + auto* model = modelValue.getDynamicObject(); + if (model == nullptr + || getModelObjectInt(model, "id", "model_id") <= 0) + { + continue; + } + const int modelToneId = + getModelObjectInt(model, "tone_id", "toneId"); + if (modelToneId > 0 && modelToneId != expectedToneId) + continue; + const auto modelArchitecture = + normaliseNAMCatalogUpdaterArchitecture( + getModelObjectString( + model, "architecture_version", "architecture")); + if (modelArchitecture.isNotEmpty() + && expected.isNotEmpty() + && modelArchitecture != expected) + { + continue; + } + accepted.add(modelValue); + } + } + return juce::var(accepted); +} + +void seedNAMCatalogModelCache( + const juce::var& catalog, + std::map& cache) +{ + auto* root = catalog.getDynamicObject(); + auto* tones = root != nullptr + ? root->getProperty("tones").getArray() + : nullptr; + if (tones == nullptr) + return; + + for (const auto& toneValue : *tones) + { + auto* tone = toneValue.getDynamicObject(); + if (tone == nullptr) + continue; + const int toneId = getModelObjectInt(tone, "id", "toneId"); + const auto architecture = normaliseNAMCatalogUpdaterArchitecture( + tone->getProperty("architecture").toString()); + if (toneId <= 0 || architecture.isEmpty()) + continue; + const auto sanitized = sanitizeNAMCatalogModels( + tone->getProperty("models"), toneId, architecture); + auto* models = sanitized.getArray(); + if (models == nullptr || models->isEmpty()) + continue; + const auto key = makeNAMCatalogModelCacheKey(toneId, architecture); + auto existing = cache.find(key); + if (existing == cache.end() + || existing->second.getArray() == nullptr + || existing->second.getArray()->size() < models->size()) + { + cache[key] = sanitized; + } + } +} + +struct NAMLibraryMultiProcessRegressionResult +{ + bool passed = false; + juce::String detail; +}; + +struct NAMLibraryChildWaitResult +{ + bool finishedNaturally = false; + bool killRequested = false; + bool stopped = false; + int exitCode = -1; +}; + +NAMLibraryChildWaitResult waitForNAMLibraryRegressionChild( + juce::ChildProcess& child, + bool started, + int naturalWaitMs, + int postKillWaitMs) +{ + NAMLibraryChildWaitResult result; + if (! started) + return result; + + result.finishedNaturally = child.waitForProcessToFinish( + juce::jmax(0, naturalWaitMs)); + if (! result.finishedNaturally) + { + result.killRequested = child.kill(); + const bool reapedAfterKill = child.waitForProcessToFinish( + juce::jmax(0, postKillWaitMs)); + result.stopped = reapedAfterKill || ! child.isRunning(); + } + else + { + result.stopped = true; + } + if (result.stopped) + result.exitCode = child.getExitCode(); + return result; +} + +bool cleanupNAMLibraryRegressionDirectory( + const juce::File& directory) +{ + if (! directory.exists()) + return true; + return directory.deleteRecursively() && ! directory.exists(); +} + +NAMLibraryMultiProcessRegressionResult +runNAMLibraryMultiProcessRegression() +{ + NAMLibraryMultiProcessRegressionResult result; + const auto executable = juce::File::getSpecialLocation( + juce::File::currentExecutableFile); + const auto regressionDirectory = juce::File::getSpecialLocation( + juce::File::tempDirectory).getChildFile( + "OpenStudio_NAM_Library_Multiprocess_" + + juce::Uuid().toString()); + const auto manifestFile = regressionDirectory.getChildFile( + "library_manifest.json"); + const auto startFile = regressionDirectory.getChildFile("start.flag"); + const auto readyA = regressionDirectory.getChildFile("ready-writer-a.flag"); + const auto readyB = regressionDirectory.getChildFile("ready-writer-b.flag"); + + juce::DynamicObject::Ptr manifest = new juce::DynamicObject(); + manifest->setProperty("schemaVersion", 1); + manifest->setProperty("installed", juce::Array()); + const bool fixtureReady = executable.existsAsFile() + && regressionDirectory.createDirectory().wasOk() + && manifestFile.replaceWithText( + juce::JSON::toString(juce::var(manifest.get()), true)); + if (! fixtureReady) + { + result.detail = "Could not prepare the isolated multi-process manifest fixture."; + (void) regressionDirectory.deleteRecursively(); + return result; + } + + const auto makeCommand = [&] (const juce::String& writerId, + const juce::File& readyFile) + { + juce::StringArray command; + command.add(executable.getFullPathName()); + command.add("--nam-library-manifest-writer-child"); + command.add("--manifest"); + command.add(manifestFile.getFullPathName()); + command.add("--writer-id"); + command.add(writerId); + command.add("--ready"); + command.add(readyFile.getFullPathName()); + command.add("--start"); + command.add(startFile.getFullPathName()); + return command; + }; + + juce::ChildProcess writerA; + juce::ChildProcess writerB; + const bool writerAStarted = writerA.start( + makeCommand("writer-a", readyA)); + const bool writerBStarted = writerB.start( + makeCommand("writer-b", readyB)); + const auto readyDeadline = juce::Time::getMillisecondCounterHiRes() + + 10000.0; + while (writerAStarted && writerBStarted + && (! readyA.existsAsFile() || ! readyB.existsAsFile()) + && writerA.isRunning() && writerB.isRunning() + && juce::Time::getMillisecondCounterHiRes() < readyDeadline) + { + juce::Thread::sleep(5); + } + const bool bothReady = readyA.existsAsFile() + && readyB.existsAsFile(); + const bool startReleased = bothReady + && startFile.replaceWithText("start"); + + const auto writerAWait = waitForNAMLibraryRegressionChild( + writerA, writerAStarted, 15000, 2000); + const auto writerBWait = waitForNAMLibraryRegressionChild( + writerB, writerBStarted, 15000, 2000); + + const auto finalManifest = juce::JSON::parse(manifestFile); + const auto* finalObject = finalManifest.getDynamicObject(); + const auto installedValue = finalObject != nullptr + ? finalObject->getProperty("installed") + : juce::var(); + const auto* installed = installedValue.getArray(); + int writerACount = 0; + int writerBCount = 0; + if (installed != nullptr) + { + for (const auto& record : *installed) + { + const auto writer = record.getProperty( + "writerId", {}).toString(); + writerACount += writer == "writer-a" ? 1 : 0; + writerBCount += writer == "writer-b" ? 1 : 0; + } + } + + const bool childrenStopped = + (! writerAStarted || writerAWait.stopped) + && (! writerBStarted || writerBWait.stopped); + const bool cleanupSucceeded = childrenStopped + && cleanupNAMLibraryRegressionDirectory( + regressionDirectory); + result.passed = writerAStarted + && writerBStarted + && bothReady + && startReleased + && writerAWait.finishedNaturally + && writerBWait.finishedNaturally + && writerAWait.exitCode == 0 + && writerBWait.exitCode == 0 + && installed != nullptr + && installed->size() == 2 + && writerACount == 1 + && writerBCount == 1 + && cleanupSucceeded; + result.detail = "writerAStarted=" + + juce::String(writerAStarted ? "true" : "false") + + ", writerBStarted=" + + juce::String(writerBStarted ? "true" : "false") + + ", bothReady=" + + juce::String(bothReady ? "true" : "false") + + ", startReleased=" + + juce::String(startReleased ? "true" : "false") + + ", writerAExitCode=" + juce::String(writerAWait.exitCode) + + ", writerBExitCode=" + juce::String(writerBWait.exitCode) + + ", installedCount=" + + juce::String(installed != nullptr ? installed->size() : -1) + + ", writerACount=" + juce::String(writerACount) + + ", writerBCount=" + juce::String(writerBCount) + + ", cleanupSucceeded=" + + juce::String(cleanupSucceeded ? "true" : "false"); + return result; +} + +NAMLibraryMultiProcessRegressionResult +runNAMLibraryProcessCleanupRegression() +{ + NAMLibraryMultiProcessRegressionResult result; + const auto executable = juce::File::getSpecialLocation( + juce::File::currentExecutableFile); + const auto regressionDirectory = juce::File::getSpecialLocation( + juce::File::tempDirectory).getChildFile( + "OpenStudio_NAM_Library_Multiprocess_" + + juce::Uuid().toString()); + const auto manifestFile = regressionDirectory.getChildFile( + "library_manifest.json"); + const auto readyFile = regressionDirectory.getChildFile( + "ready-cleanup-writer.flag"); + const auto startFile = regressionDirectory.getChildFile( + "start.flag"); + const auto manifest = makeEmptyNAMLibraryManifest(); + const bool fixtureReady = executable.existsAsFile() + && regressionDirectory.createDirectory().wasOk() + && manifestFile.replaceWithText( + juce::JSON::toString(manifest, true)); + if (! fixtureReady) + { + result.detail = + "Could not prepare the child-cleanup fixture."; + (void) cleanupNAMLibraryRegressionDirectory( + regressionDirectory); + return result; + } + + juce::StringArray command; + command.add(executable.getFullPathName()); + command.add("--nam-library-manifest-writer-child"); + command.add("--manifest"); + command.add(manifestFile.getFullPathName()); + command.add("--writer-id"); + command.add("cleanup-writer"); + command.add("--ready"); + command.add(readyFile.getFullPathName()); + command.add("--start"); + command.add(startFile.getFullPathName()); + juce::ChildProcess child; + const bool started = child.start(command); + const auto readyDeadline = + juce::Time::getMillisecondCounterHiRes() + 5000.0; + while (started && ! readyFile.existsAsFile() + && child.isRunning() + && juce::Time::getMillisecondCounterHiRes() + < readyDeadline) + { + juce::Thread::sleep(5); + } + const bool becameReady = readyFile.existsAsFile(); + const auto wait = waitForNAMLibraryRegressionChild( + child, started, 25, 2000); + const bool childStopped = ! started || wait.stopped; + const bool cleanupSucceeded = childStopped + && cleanupNAMLibraryRegressionDirectory( + regressionDirectory); + result.passed = started + && becameReady + && ! wait.finishedNaturally + && wait.killRequested + && wait.stopped + && cleanupSucceeded; + result.detail = "started=" + + juce::String(started ? "true" : "false") + + ", becameReady=" + + juce::String(becameReady ? "true" : "false") + + ", killRequested=" + + juce::String(wait.killRequested ? "true" : "false") + + ", stoppedAfterKill=" + + juce::String(wait.stopped ? "true" : "false") + + ", cleanupSucceeded=" + + juce::String(cleanupSucceeded ? "true" : "false"); + return result; +} + +juce::var runNAMCatalogNativeRegressionImpl() +{ + juce::Array checks; + bool overallPass = true; + const auto addCheck = [&checks, &overallPass] + (const juce::String& id, + bool pass, + const juce::String& detail) + { + juce::DynamicObject::Ptr check = new juce::DynamicObject(); + check->setProperty("id", id); + check->setProperty("pass", pass); + check->setProperty("detail", detail); + checks.add(juce::var(check.get())); + overallPass = overallPass && pass; + }; + const auto makeModel = [] (int modelId, + int toneId, + const juce::String& architecture) + { + juce::DynamicObject::Ptr model = new juce::DynamicObject(); + model->setProperty("id", modelId); + model->setProperty("tone_id", toneId); + model->setProperty("architecture_version", architecture); + return juce::var(model.get()); + }; + const auto makeTone = [&makeModel] + (int toneId, + const juce::String& architecture, + int modelId) + { + juce::DynamicObject::Ptr tone = new juce::DynamicObject(); + tone->setProperty("id", toneId); + tone->setProperty("architecture", architecture); + juce::Array models; + models.add(makeModel(modelId, toneId, architecture)); + tone->setProperty("models", juce::var(models)); + return juce::var(tone.get()); + }; + + juce::Array oldRows; + oldRows.add(makeTone(7, "1", 701)); + oldRows.add(makeTone(7, "2", 702)); + juce::DynamicObject::Ptr oldRoot = new juce::DynamicObject(); + oldRoot->setProperty("tones", juce::var(oldRows)); + std::map cache; + seedNAMCatalogModelCache(juce::var(oldRoot.get()), cache); + const auto a1Key = makeNAMCatalogModelCacheKey(7, "1"); + const auto a2Key = makeNAMCatalogModelCacheKey(7, "2"); + const bool priorArchitecturesHydrated = + cache.find(a1Key) != cache.end() + && cache.find(a2Key) != cache.end(); + addCheck( + "existing_catalog_models_seed_both_architectures", + priorArchitecturesHydrated, + "Verified cached A1 and A2 model arrays must remain available when the native fetch budget is exhausted."); + + juce::Array fetchedModels; + fetchedModels.add(makeModel(703, 7, "1")); + fetchedModels.add(makeModel(999, 99, "1")); + cache[a1Key] = sanitizeNAMCatalogModels( + juce::var(fetchedModels), 7, "1"); + bool everyDuplicateHydrated = true; + for (int duplicate = 0; duplicate < 3; ++duplicate) + { + auto found = cache.find(a1Key); + auto* models = found != cache.end() + ? found->second.getArray() + : nullptr; + everyDuplicateHydrated = everyDuplicateHydrated + && models != nullptr + && models->size() == 1 + && static_cast( + models->getReference(0).getProperty("id", 0)) + == 703; + } + addCheck( + "duplicate_sort_rows_share_sanitized_models", + everyDuplicateHydrated, + "Every sort-bucket duplicate must receive the same freshly fetched tone/architecture models, with mismatched tone IDs rejected."); + + auto a2 = cache.find(a2Key); + auto* a2Models = a2 != cache.end() + ? a2->second.getArray() + : nullptr; + const bool unfetchedA2Preserved = a2Models != nullptr + && a2Models->size() == 1 + && static_cast( + a2Models->getReference(0).getProperty("id", 0)) + == 702; + addCheck( + "unfetched_architecture_uses_verified_cache", + unfetchedA2Preserved, + "Rows beyond maxModelFetches must retain verified prior model arrays instead of losing multi-capture choices."); + + int largeCapturePageRequests = 0; + const auto largeCapturePack = fetchTone3000ModelPages( + 8, + "2", + kTone3000CatalogModelPageSize, + [&] (int page, int pageSize) + { + ++largeCapturePageRequests; + juce::Array pageModels; + const int firstIndex = (page - 1) * pageSize; + const int endIndex = juce::jmin( + firstIndex + pageSize, 137); + for (int index = firstIndex; index < endIndex; ++index) + pageModels.add(makeModel(800 + index, 8, "2")); + + juce::DynamicObject::Ptr payload = new juce::DynamicObject(); + payload->setProperty("data", juce::var(pageModels)); + payload->setProperty("page", page); + payload->setProperty("page_size", pageSize); + payload->setProperty("total", 137); + payload->setProperty("total_pages", 2); + payload->setProperty("has_more", page < 2); + payload->setProperty( + "next_page", + page < 2 ? juce::var(page + 1) : juce::var()); + return juce::var(payload.get()); + }); + const bool largeCapturePackPass = + largeCapturePack.error.isVoid() + && largeCapturePageRequests == 2 + && largeCapturePack.pagesFetched == 2 + && largeCapturePack.models.size() == 137 + && static_cast( + largeCapturePack.models.getFirst().getProperty("id", 0)) + == 800 + && static_cast( + largeCapturePack.models.getLast().getProperty("id", 0)) + == 936; + addCheck( + "catalog_paginates_capture_packs_above_one_hundred_models", + largeCapturePackPass, + "The native catalog and live tone detail path must request every bounded model page and preserve all 137 captures without duplicates or truncation."); + + const auto overLimitCapturePack = fetchTone3000ModelPages( + 9, + "2", + kTone3000CatalogModelPageSize, + [&makeModel] (int page, int pageSize) + { + juce::Array pageModels; + pageModels.add(makeModel(900, 9, "2")); + juce::DynamicObject::Ptr payload = new juce::DynamicObject(); + payload->setProperty("data", juce::var(pageModels)); + payload->setProperty("page", page); + payload->setProperty("page_size", pageSize); + payload->setProperty( + "total_pages", kTone3000MaximumModelPages + 1); + return juce::var(payload.get()); + }); + addCheck( + "catalog_rejects_unbounded_capture_pagination", + ! overLimitCapturePack.error.isVoid(), + "A provider response beyond the bounded per-tone page limit must fail explicitly instead of publishing a silently truncated capture list."); + + std::atomic firstRefreshEntered { false }; + std::atomic releaseFirstRefresh { false }; + std::atomic secondRefreshEntered { false }; + std::thread firstRefresh([&] + { + const ScopedTone3000CatalogRefreshLock lock(2000); + firstRefreshEntered.store(lock.locked, std::memory_order_release); + while (lock.locked + && ! releaseFirstRefresh.load( + std::memory_order_acquire)) + { + juce::Thread::sleep(1); + } + }); + for (int attempt = 0; + attempt < 400 + && ! firstRefreshEntered.load(std::memory_order_acquire); + ++attempt) + { + juce::Thread::sleep(5); + } + std::thread secondRefresh([&] + { + const ScopedTone3000CatalogRefreshLock lock(2000); + secondRefreshEntered.store(lock.locked, std::memory_order_release); + }); + juce::Thread::sleep(50); + const bool secondRefreshWasBlocked = + ! secondRefreshEntered.load(std::memory_order_acquire); + releaseFirstRefresh.store(true, std::memory_order_release); + firstRefresh.join(); + secondRefresh.join(); + const bool catalogRefreshSerializationPass = + firstRefreshEntered.load(std::memory_order_acquire) + && secondRefreshWasBlocked + && secondRefreshEntered.load(std::memory_order_acquire); + addCheck( + "catalog_refresh_threads_are_process_local_single_flight", + catalogRefreshSerializationPass, + "Concurrent refresh workers in one process must serialize before attempting the separately configured inter-process publication lock."); + + const auto manifestMultiProcessRegression = + runNAMLibraryMultiProcessRegression(); + addCheck( + "nam_library_manifest_multi_process_writers_preserved", + manifestMultiProcessRegression.passed, + "Two real OpenStudio child processes must preserve both concurrent manifest records under the shared OS lock. " + + manifestMultiProcessRegression.detail); + + const auto processCleanupRegression = + runNAMLibraryProcessCleanupRegression(); + addCheck( + "nam_library_child_timeout_is_reaped_before_cleanup", + processCleanupRegression.passed, + "A timed-out real child process must be killed, observed stopped with a bounded wait, and have its isolated fixture removed. " + + processCleanupRegression.detail); + + std::atomic firstTokenRefreshEntered { false }; + std::atomic releaseFirstTokenRefresh { false }; + std::atomic secondTokenRefreshEntered { false }; + std::thread firstTokenRefresh([&] + { + const ScopedTone3000TokenRefreshLock lock(2000); + firstTokenRefreshEntered.store( + lock.locked, std::memory_order_release); + while (lock.locked + && ! releaseFirstTokenRefresh.load( + std::memory_order_acquire)) + { + juce::Thread::sleep(1); + } + }); + for (int attempt = 0; + attempt < 400 + && ! firstTokenRefreshEntered.load( + std::memory_order_acquire); + ++attempt) + { + juce::Thread::sleep(5); + } + std::thread secondTokenRefresh([&] + { + const ScopedTone3000TokenRefreshLock lock(2000); + secondTokenRefreshEntered.store( + lock.locked, std::memory_order_release); + }); + juce::Thread::sleep(50); + const bool secondTokenRefreshWasBlocked = + ! secondTokenRefreshEntered.load( + std::memory_order_acquire); + releaseFirstTokenRefresh.store(true, std::memory_order_release); + firstTokenRefresh.join(); + secondTokenRefresh.join(); + const bool tokenRefreshSerializationPass = + firstTokenRefreshEntered.load( + std::memory_order_acquire) + && secondTokenRefreshWasBlocked + && secondTokenRefreshEntered.load( + std::memory_order_acquire); + addCheck( + "token_refresh_threads_are_process_local_single_flight", + tokenRefreshSerializationPass, + "Concurrent refresh workers in one process must serialize before attempting the separately configured inter-process token-rotation lock."); + + const bool retryAfterPass = + std::abs(parseTone3000RetryAfterSeconds({}) - 15.0) < 0.001 + && std::abs(parseTone3000RetryAfterSeconds("0") - 15.0) < 0.001 + && std::abs(parseTone3000RetryAfterSeconds("2.5") - 2.5) < 0.001 + && std::abs(parseTone3000RetryAfterSeconds("120") - 60.0) < 0.001; + addCheck( + "retry_after_default_and_clamp", + retryAfterPass, + "HTTP 429 Retry-After must preserve numeric seconds, default conservatively to 15 seconds, and clamp to 1-60 seconds."); + + const bool credentialGenerationPass = + tone3000CredentialSnapshotStillCurrent( + 10, "token-a", 10, "token-a") + && ! tone3000CredentialSnapshotStillCurrent( + 10, "token-a", 11, "token-a") + && ! tone3000CredentialSnapshotStillCurrent( + 10, "token-a", 10, "token-b") + && tone3000RecordCanBeInvalidated( + tone3000CredentialSessionId, 0, 20) + && tone3000RecordCanBeInvalidated( + tone3000CredentialSessionId, 19, 20) + && tone3000RecordCanBeInvalidated( + tone3000CredentialSessionId, 20, 20) + && ! tone3000RecordCanBeInvalidated( + tone3000CredentialSessionId, 21, 20) + && tone3000RecordCanBeInvalidated( + "prior-or-other-process", 999, 20) + && tone3000PendingRecordCanBeInvalidated( + tone3000CredentialSessionId, 19, 20) + && ! tone3000PendingRecordCanBeInvalidated( + "other-live-process", 1, 20); + addCheck( + "credential_epoch_and_identity_reject_stale_publication", + credentialGenerationPass, + "A refresh/exchange snapshot must be rejected after clear, cancel, a newer sign-in, or token rotation; cleanup must preserve newer records and pending PKCE state owned by another live instance."); + + const auto libraryReliability = + runNAMLibraryReliabilityRegressionImpl(); + overallPass = overallPass + && static_cast(libraryReliability.getProperty( + "success", false)); + const auto reliabilityChecksValue = + libraryReliability.getProperty("checks", {}); + if (auto* reliabilityChecks = reliabilityChecksValue.getArray()) + { + for (const auto& check : *reliabilityChecks) + checks.add(check); + } + + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", overallPass); + result->setProperty("objectiveGateStatus", + overallPass ? "pass" : "fail"); + result->setProperty("checks", juce::var(checks)); + return juce::var(result.get()); +} + +juce::var refreshNAMCatalogFromUpdater(juce::var optionsVar) +{ + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + const ScopedTone3000CatalogRefreshLock refreshLock; + if (! refreshLock.locked) + { + result->setProperty("exitCode", 2); + result->setProperty( + "error", "NAM catalog refresh was canceled or timed out while waiting for another refresh to finish."); + return juce::var(result.get()); + } + const auto namRoot = getOpenStudioNAMRoot(); + const auto jsonFile = getOpenStudioNAMCatalogJson(); + + auto* options = optionsVar.getDynamicObject(); + auto optionProperty = [] (juce::DynamicObject* object, + const juce::String& primary, + const juce::String& fallback, + juce::var defaultValue) + { + if (object == nullptr) + return defaultValue; + + auto value = object->getProperty(primary); + if (value.isVoid() && fallback.isNotEmpty()) + value = object->getProperty(fallback); + + return value.isVoid() ? defaultValue : value; + }; + + const int pageSize = juce::jlimit(1, 25, static_cast(optionProperty(options, "page_size", "pageSize", 25))); + const int pages = juce::jlimit(1, 3, static_cast(optionProperty(options, "pages", {}, 1))); + const int maxModelFetches = juce::jlimit(0, 200, static_cast(optionProperty(options, "max_model_fetches", "maxModelFetches", 60))); + const double minInterval = juce::jlimit(0.25, 5.0, static_cast(optionProperty(options, "min_interval", "minInterval", 0.75))); + const auto gears = optionProperty(options, "gears", {}, "amp_amp-cab").toString().trim(); + const auto query = optionProperty(options, "query", {}, "").toString(); + const auto architecture = normaliseNAMCatalogUpdaterArchitecture(optionProperty(options, "architecture", {}, "").toString()); + const auto startMs = juce::Time::getMillisecondCounterHiRes(); + result->setProperty("exitCode", 0); + result->setProperty("timedOut", false); + result->setProperty("updater", "native-cpp"); + result->setProperty("rootPath", namRoot.getFullPathName()); + result->setProperty("catalogJsonPath", jsonFile.getFullPathName()); + result->setProperty("pageSize", pageSize); + result->setProperty("pages", pages); + result->setProperty("maxModelFetches", maxModelFetches); + + auto accessToken = getStoredTone3000AccessToken(); + if (accessToken.isEmpty()) + { + const auto refreshResult = refreshTone3000Auth({}); + if (isTone3000ErrorPayload(refreshResult)) + { + const auto* refreshObject = refreshResult.getDynamicObject(); + result->setProperty("error", refreshObject != nullptr + ? refreshObject->getProperty("error") + : juce::var("Connect TONE3000 before refreshing the NAM catalog.")); + result->setProperty("exitCode", 2); + return juce::var(result.get()); + } + accessToken = getStoredTone3000AccessToken(); + } + if (accessToken.isEmpty()) + { + result->setProperty("error", "Connect TONE3000 before refreshing the NAM catalog."); + result->setProperty("exitCode", 2); + return juce::var(result.get()); + } + + juce::StringArray architectures; + if (architecture.isNotEmpty()) + architectures.add(architecture); + else + { + architectures.add("1"); + architectures.add("2"); + } + juce::StringArray sorts; + sorts.add("newest"); + sorts.add("trending"); + sorts.add("downloads-all-time"); + + const auto existingCatalog = + parseJsonFileOrDefault(jsonFile, "tones"); + std::map modelCache; + seedNAMCatalogModelCache(existingCatalog, modelCache); + juce::Array rows; + std::set fetchedModelKeys; + int modelFetchCount = 0; + double lastRequestMs = 0.0; + const auto waitForRequestSlot = [&] + { + const double now = juce::Time::getMillisecondCounterHiRes(); + const double waitMs = minInterval * 1000.0 - (now - lastRequestMs); + if (waitMs > 0.0) + { + if (! sleepForTone3000Task( + juce::jlimit( + 1, 5000, juce::roundToInt(waitMs)))) + { + return false; + } + } + lastRequestMs = juce::Time::getMillisecondCounterHiRes(); + return ! isTone3000TaskCancelled(); + }; + const auto requestCatalogJson = [&] (const juce::URL& url, + const juce::String& label) + { + juce::var payload; + constexpr int maximumRateLimitRetries = 3; + for (int attempt = 0; attempt <= maximumRateLimitRetries; ++attempt) + { + if (juce::Time::getMillisecondCounterHiRes() - startMs + >= 180000.0) + { + result->setProperty("timedOut", true); + return makeTone3000Error( + "NAM catalog refresh timed out."); + } + if (! waitForRequestSlot()) + { + return makeTone3000Error( + "NAM catalog refresh was canceled."); + } + payload = getTone3000Json(url, accessToken, label); + auto* object = payload.getDynamicObject(); + const int statusCode = object != nullptr + ? static_cast(object->getProperty("statusCode")) + : 0; + if (! isTone3000ErrorPayload(payload) + || statusCode != 429 + || attempt == maximumRateLimitRetries) + { + return payload; + } + double retryAfterSeconds = static_cast( + object->getProperty("retryAfterSeconds")); + if (! std::isfinite(retryAfterSeconds) + || retryAfterSeconds <= 0.0) + { + retryAfterSeconds = 15.0; + } + if (! sleepForTone3000Task(juce::roundToInt( + juce::jlimit(1.0, 60.0, retryAfterSeconds) + * 1000.0))) + { + return makeTone3000Error( + "NAM catalog refresh was canceled."); + } + } + return payload; + }; + const auto fail = [&] (const juce::var& payload, + const juce::String& fallback) -> juce::var + { + auto* object = payload.getDynamicObject(); + result->setProperty("error", object != nullptr + ? object->getProperty("error").toString() + : fallback); + result->setProperty("exitCode", 2); + result->setProperty("durationMs", + juce::Time::getMillisecondCounterHiRes() - startMs); + result->setProperty("catalog", existingCatalog); + return juce::var(result.get()); + }; + + int rank = 0; + for (const auto& architectureValue : architectures) + { + for (const auto& sort : sorts) + { + for (int page = 1; page <= pages; ++page) + { + if (isTone3000TaskCancelled()) + return fail({}, "NAM catalog refresh was canceled."); + auto searchUrl = juce::URL("https://www.tone3000.com/api/v1/tones/search") + .withParameter("query", query) + .withParameter("page", juce::String(page)) + .withParameter("page_size", juce::String(pageSize)) + .withParameter("sort", sort) + .withParameter("gears", gears.isNotEmpty() ? gears : juce::String("amp_amp-cab")) + .withParameter("platform", "nam") + .withParameter("architecture", architectureValue); + auto searchPayload = requestCatalogJson( + searchUrl, "TONE3000 catalog search"); + if (isTone3000ErrorPayload(searchPayload)) + return fail(searchPayload, "TONE3000 catalog search failed."); + + auto* searchObject = searchPayload.getDynamicObject(); + if (searchObject == nullptr) + return fail({}, "TONE3000 catalog search returned invalid JSON."); + auto tonesValue = searchObject->getProperty("data"); + if (! tonesValue.isArray()) + tonesValue = searchObject->getProperty("tones"); + auto* tones = tonesValue.getArray(); + if (tones == nullptr) + continue; + + for (auto toneValue : *tones) + { + auto* tone = toneValue.getDynamicObject(); + if (tone == nullptr) + continue; + const int toneId = getModelObjectInt(tone, "id", "toneId"); + if (toneId <= 0) + continue; + ++rank; + tone->setProperty("sortBucket", sort); + tone->setProperty("architecture", architectureValue); + tone->setProperty("catalogRank", rank); + + const auto modelKey = makeNAMCatalogModelCacheKey( + toneId, architectureValue); + if (fetchedModelKeys.find(modelKey) + == fetchedModelKeys.end() + && modelFetchCount < maxModelFetches) + { + fetchedModelKeys.insert(modelKey); + ++modelFetchCount; + const auto pageResult = fetchTone3000ModelPages( + toneId, + architectureValue, + kTone3000CatalogModelPageSize, + [&] (int modelPage, int modelPageSize) + { + const auto modelsUrl = juce::URL( + "https://www.tone3000.com/api/v1/models") + .withParameter( + "tone_id", juce::String(toneId)) + .withParameter( + "page", juce::String(modelPage)) + .withParameter( + "page_size", + juce::String(modelPageSize)) + .withParameter( + "architecture", + architectureValue); + return requestCatalogJson( + modelsUrl, + "TONE3000 catalog model list"); + }); + if (! pageResult.error.isVoid()) + { + return fail( + pageResult.error, + "TONE3000 catalog model pagination failed."); + } + modelCache[modelKey] = sanitizeNAMCatalogModels( + juce::var(pageResult.models), + toneId, + architectureValue); + } + const auto cachedModels = modelCache.find(modelKey); + tone->setProperty( + "models", + cachedModels != modelCache.end() + ? cachedModels->second + : juce::var(juce::Array())); + rows.add(toneValue); + } + } + } + } + + juce::DynamicObject::Ptr catalogObject = new juce::DynamicObject(); + catalogObject->setProperty("schemaVersion", 1); + const auto generatedAt = juce::Time::getCurrentTime().toISO8601(true); + catalogObject->setProperty("generatedAt", generatedAt); + catalogObject->setProperty("source", "tone3000"); + juce::DynamicObject::Ptr queryObject = new juce::DynamicObject(); + queryObject->setProperty("platform", "nam"); + queryObject->setProperty("gears", gears.isNotEmpty() ? gears : juce::String("amp_amp-cab")); + juce::Array architectureValues; + for (const auto& value : architectures) + architectureValues.add(value); + juce::Array sortValues; + for (const auto& value : sorts) + sortValues.add(value); + queryObject->setProperty("architecture", juce::var(architectureValues)); + queryObject->setProperty("sort", juce::var(sortValues)); + queryObject->setProperty("pageSize", pageSize); + queryObject->setProperty("pages", pages); + catalogObject->setProperty("query", juce::var(queryObject.get())); + catalogObject->setProperty("tones", juce::var(rows)); + const juce::var catalog(catalogObject.get()); + + if (isTone3000TaskCancelled()) + return fail({}, "NAM catalog refresh was canceled before publication."); + if (namRoot.createDirectory().failed()) + return fail({}, "Could not create the NAM catalog directory."); + juce::TemporaryFile temporaryCatalog( + jsonFile, juce::TemporaryFile::useHiddenFile); + if (! temporaryCatalog.getFile().replaceWithText( + juce::JSON::toString(catalog, true)) + || ! temporaryCatalog.overwriteTargetFileWithTemporary()) + { + return fail({}, "Could not publish catalog.json."); + } + + bool legacyCatalogCleanupComplete = true; + for (const auto& legacyName : { + juce::String("catalog.sqlite"), + juce::String("catalog.sqlite-wal"), + juce::String("catalog.sqlite-shm") }) + { + const auto legacyFile = namRoot.getChildFile(legacyName); + if (legacyFile.existsAsFile() && ! legacyFile.deleteFile()) + legacyCatalogCleanupComplete = false; + } + + result->setProperty("success", true); + result->setProperty("durationMs", + juce::Time::getMillisecondCounterHiRes() - startMs); + result->setProperty("output", "Native catalog refresh cached " + + juce::String(rows.size()) + " tone rows"); + result->setProperty("toneRows", rows.size()); + result->setProperty("generatedAt", generatedAt); + result->setProperty("legacyCatalogCleanupComplete", + legacyCatalogCleanupComplete); + result->setProperty("catalog", catalog); + return juce::var(result.get()); +} + +juce::String getModelObjectString(juce::DynamicObject* model, const juce::String& primary, const juce::String& fallback) +{ + if (model == nullptr) + return {}; + auto value = model->getProperty(primary).toString(); + if (value.isEmpty() && fallback.isNotEmpty()) + value = model->getProperty(fallback).toString(); + return value; +} + +juce::String normaliseNAMSha256(juce::String checksum) +{ + checksum = checksum.trim().toLowerCase(); + if (checksum.startsWith("sha256:") || checksum.startsWith("sha256=")) + checksum = checksum.substring(7).trim(); + return checksum; +} + +bool calculateNAMAssetSha256(const juce::File& file, + juce::String& actualSha256, + juce::String& error) +{ + if (! file.existsAsFile()) + { + error = "The NAM or IR asset could not be found."; + return false; + } + + juce::FileInputStream input(file); + if (! input.openedOk()) + { + error = "OpenStudio could not read the NAM or IR asset."; + return false; + } + + class CancellableHashInput final : public juce::InputStream + { + public: + explicit CancellableHashInput(juce::InputStream& sourceIn) + : source(sourceIn) + { + } + + juce::int64 getTotalLength() override + { + return source.getTotalLength(); + } + + bool isExhausted() override + { + return canceled || source.isExhausted(); + } + + juce::int64 getPosition() override + { + return source.getPosition(); + } + + bool setPosition(juce::int64 position) override + { + return source.setPosition(position); + } + + int read(void* destination, int maximumBytes) override + { + if (isTone3000TaskCancelled()) + { + canceled = true; + return 0; + } + constexpr int maximumChunkBytes = 64 * 1024; + return source.read( + destination, + juce::jmin(maximumBytes, maximumChunkBytes)); + } + + bool wasCanceled() const noexcept { return canceled; } + + private: + juce::InputStream& source; + bool canceled = false; + } cancellableInput(input); + + const auto hash = juce::SHA256(cancellableInput); + if (cancellableInput.wasCanceled()) + { + error = "The NAM asset search was canceled while checking a file."; + return false; + } + actualSha256 = hash.toHexString().toLowerCase(); + return true; +} + +juce::var inspectNAMAssetFile(const juce::String& filePath) +{ + struct CachedInspection + { + juce::String path; + juce::int64 fileSize = 0; + juce::int64 modificationTimeMs = 0; + juce::String checksum; + }; + static juce::CriticalSection cacheLock; + static std::vector cache; + + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + const juce::File file(filePath); + result->setProperty("success", false); + result->setProperty("exists", file.existsAsFile()); + result->setProperty("path", file.getFullPathName()); + result->setProperty("fileName", file.getFileName()); + result->setProperty("extension", file.getFileExtension().toLowerCase()); + + const auto canonicalPath = file.getFullPathName(); + const auto fileSize = file.getSize(); + const auto modificationTimeMs = + file.getLastModificationTime().toMilliseconds(); + juce::String checksum; + { + const juce::ScopedLock lock(cacheLock); + for (const auto& entry : cache) + { + if (entry.path == canonicalPath + && entry.fileSize == fileSize + && entry.modificationTimeMs + == modificationTimeMs) + { + checksum = entry.checksum; + break; + } + } + } + + juce::String error; + if (checksum.isEmpty() + && ! calculateNAMAssetSha256(file, checksum, error)) + { + result->setProperty("error", error); + return juce::var(result.get()); + } + if (checksum.isNotEmpty()) + { + const juce::ScopedLock lock(cacheLock); + auto existing = std::find_if( + cache.begin(), + cache.end(), + [&canonicalPath] (const CachedInspection& entry) + { + return entry.path == canonicalPath; + }); + const CachedInspection newEntry { + canonicalPath, + fileSize, + modificationTimeMs, + checksum + }; + if (existing != cache.end()) + *existing = newEntry; + else + cache.push_back(newEntry); + constexpr size_t maximumCachedAssets = 512; + if (cache.size() > maximumCachedAssets) + cache.erase(cache.begin()); + } + + result->setProperty("success", true); + result->setProperty("checksum", checksum); + result->setProperty("assetId", "sha256:" + checksum); + result->setProperty("fileSizeBytes", static_cast(fileSize)); + return juce::var(result.get()); +} + +bool isAllowedNAMRelinkCandidate(const juce::File& file, const juce::String& slot) +{ + const auto extension = file.getFileExtension().toLowerCase(); + if (slot == "cab") + return extension == ".wav" || extension == ".aif" || extension == ".aiff" + || extension == ".flac" || extension == ".ogg"; + + return extension == ".nam"; +} + +juce::var findNAMAssetInDirectory(const juce::String& directoryPath, + const juce::String& expectedFileName, + const juce::String& expectedChecksum, + juce::int64 expectedSize, + const juce::String& slot) +{ + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + const juce::File directory(directoryPath); + if (! directory.isDirectory()) + { + result->setProperty("error", "The selected search folder is not available."); + return juce::var(result.get()); + } + + const auto checksum = normaliseNAMSha256(expectedChecksum); + const bool checksumSupplied = checksum.isNotEmpty(); + if (checksumSupplied && (checksum.length() != 64 || ! checksum.containsOnly("0123456789abcdef"))) + { + result->setProperty("error", "The project contains an invalid NAM asset checksum."); + return juce::var(result.get()); + } + + constexpr int maxCandidateFiles = 10000; + juce::Array candidates; + bool truncated = false; + for (const auto& entry : juce::RangedDirectoryIterator( + directory, true, "*", juce::File::findFiles)) + { + if (isTone3000TaskCancelled()) + { + result->setProperty("error", "The NAM asset search was canceled."); + result->setProperty("canceled", true); + return juce::var(result.get()); + } + const auto file = entry.getFile(); + if (! isAllowedNAMRelinkCandidate(file, slot)) + continue; + if (slot != "cab" + && file.getSize() + > OpenStudioNAMModelSafety::maximumFileBytes) + { + continue; + } + if (candidates.size() >= maxCandidateFiles) + { + truncated = true; + break; + } + candidates.add(file); + } + + result->setProperty("checkedFiles", candidates.size()); + result->setProperty("truncated", truncated); + + if (checksumSupplied) + { + // Same-sized and same-named files are checked first, but the checksum is + // authoritative and still permits users to rename or move the asset. + for (int pass = 0; pass < 2; ++pass) + { + for (const auto& candidate : candidates) + { + if (isTone3000TaskCancelled()) + { + result->setProperty( + "error", "The NAM asset search was canceled."); + result->setProperty("canceled", true); + return juce::var(result.get()); + } + const bool sameName = candidate.getFileName().equalsIgnoreCase(expectedFileName); + const bool sameSize = expectedSize <= 0 || candidate.getSize() == expectedSize; + if (! sameSize + || (pass == 0 && ! sameName) + || (pass == 1 && sameName)) + { + continue; + } + + juce::String actualChecksum; + juce::String hashError; + if (calculateNAMAssetSha256(candidate, actualChecksum, hashError) + && actualChecksum == checksum) + { + result->setProperty("success", true); + result->setProperty("foundPath", candidate.getFullPathName()); + result->setProperty("matchReason", "checksum"); + return juce::var(result.get()); + } + } + } + + result->setProperty("error", truncated + ? "No checksum match was found before the search limit was reached. Choose a narrower folder." + : "No file with the project asset checksum was found in this folder."); + return juce::var(result.get()); + } + + juce::Array filenameMatches; + for (const auto& candidate : candidates) + { + if (candidate.getFileName().equalsIgnoreCase(expectedFileName) + && (expectedSize <= 0 || candidate.getSize() == expectedSize)) + { + filenameMatches.add(candidate); + } + } + + if (filenameMatches.size() == 1) + { + result->setProperty("success", true); + result->setProperty("foundPath", filenameMatches.getFirst().getFullPathName()); + result->setProperty("matchReason", expectedSize > 0 ? "filename-and-size" : "filename"); + return juce::var(result.get()); + } + + result->setProperty("ambiguous", filenameMatches.size() > 1); + result->setProperty("error", filenameMatches.size() > 1 + ? "More than one unverified filename match was found. Locate the exact file manually." + : "No matching asset filename was found in this folder."); + return juce::var(result.get()); +} + +bool verifyNAMFileSha256(const juce::File& file, + const juce::String& expectedSha256, + juce::String& actualSha256, + juce::String& error) +{ + const auto normalizedExpected = normaliseNAMSha256(expectedSha256); + if (expectedSha256.trim().isNotEmpty() + && (normalizedExpected.length() != 64 + || ! normalizedExpected.containsOnly("0123456789abcdef"))) + { + error = "The NAM catalog supplied an invalid SHA-256 checksum."; + return false; + } + + if (! calculateNAMAssetSha256(file, actualSha256, error)) + return false; + if (normalizedExpected.isEmpty() || actualSha256 == normalizedExpected) + return true; + + error = "The downloaded NAM file failed SHA-256 verification."; + return false; +} + +bool isTrustedTone3000DownloadHost(juce::String domain) +{ + domain = domain.trim().toLowerCase(); + return domain == "tone3000.com" + || domain == "www.tone3000.com" + || domain.endsWith(".tone3000.com"); +} + +bool isSecureNAMDownloadURL(const juce::URL& url) +{ + return url.getScheme().equalsIgnoreCase("https") && url.getDomain().isNotEmpty(); +} + +bool isAllowedExternalBrowserURL(juce::String rawURL) +{ + rawURL = rawURL.trim(); + if (rawURL.isEmpty() + || rawURL.containsAnyOf("\r\n\t") + || (! rawURL.startsWithIgnoreCase("https://") + && ! rawURL.startsWithIgnoreCase("http://"))) + { + return false; + } + + const juce::URL url(rawURL); + const auto scheme = url.getScheme(); + return (scheme.equalsIgnoreCase("https") || scheme.equalsIgnoreCase("http")) + && url.getDomain().isNotEmpty(); +} + +juce::URL resolveNAMDownloadRedirect(const juce::URL& currentURL, juce::String location) +{ + location = location.trim(); + if (location.startsWithIgnoreCase("https://") || location.startsWithIgnoreCase("http://")) + return juce::URL(location); + + if (location.startsWith("//")) + return juce::URL("https:" + location); + + if (location.startsWithChar('/')) + return juce::URL(currentURL.getOrigin() + location); + + return currentURL.getParentURL().getChildURL(location); +} + +struct NAMDownloadStream +{ + std::unique_ptr input; + int statusCode = 0; + juce::String error; +}; + +NAMDownloadStream openAuthenticatedNAMDownload(const juce::String& modelURL, + const juce::String& accessToken) +{ + NAMDownloadStream result; + if (accessToken.containsAnyOf("\r\n")) + { + result.error = "The TONE3000 access token contains invalid header characters"; + return result; + } + + auto currentURL = juce::URL(modelURL); + if (! isSecureNAMDownloadURL(currentURL) + || ! isTrustedTone3000DownloadHost(currentURL.getDomain())) + { + result.error = "TONE3000 model_url must use HTTPS on an official tone3000.com host"; + return result; + } + + constexpr int maxRedirects = 5; + for (int redirectCount = 0; redirectCount <= maxRedirects; ++redirectCount) + { + if (isTone3000TaskCancelled()) + { + result.error = "The TONE3000 model download was canceled"; + return result; + } + const bool trustedDestination = isTrustedTone3000DownloadHost(currentURL.getDomain()); + juce::String headers; + if (trustedDestination && accessToken.isNotEmpty()) + headers << "Authorization: Bearer " << accessToken << "\r\n"; + + juce::StringPairArray responseHeaders; + result.statusCode = 0; + auto input = currentURL.createInputStream( + juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withExtraHeaders(headers) + .withConnectionTimeoutMs(30000) + .withResponseHeaders(&responseHeaders) + .withNumRedirectsToFollow(0) + .withStatusCode(&result.statusCode)); + + if (result.statusCode < 300 || result.statusCode >= 400) + { + result.input = std::move(input); + return result; + } + + if (redirectCount == maxRedirects) + { + result.error = "TONE3000 model download exceeded the redirect limit"; + return result; + } + + const auto location = responseHeaders.getValue("Location", {}).trim(); + if (location.isEmpty()) + { + result.error = "TONE3000 model download returned a redirect without a Location header"; + return result; + } + + auto redirectURL = resolveNAMDownloadRedirect(currentURL, location); + if (! isSecureNAMDownloadURL(redirectURL)) + { + result.error = "TONE3000 model download refused an insecure redirect"; + return result; + } + + // A CDN redirect is allowed, but this loop creates a fresh request and + // only sends the Bearer token when the destination is still TONE3000. + input.reset(); + currentURL = std::move(redirectURL); + } + + result.error = "TONE3000 model download could not be opened"; + return result; +} + +int getModelObjectInt(juce::DynamicObject* model, const juce::String& primary, const juce::String& fallback) +{ + if (model == nullptr) + return 0; + auto value = model->getProperty(primary); + if (value.isVoid() && fallback.isNotEmpty()) + value = model->getProperty(fallback); + return static_cast(value); +} + +juce::var makeNAMLibraryInfo() +{ + juce::DynamicObject::Ptr root = new juce::DynamicObject(); + const auto namRoot = getOpenStudioNAMRoot(); + root->setProperty("rootPath", namRoot.getFullPathName()); + root->setProperty("libraryPath", namRoot.getChildFile("library").getFullPathName()); + root->setProperty("catalogJsonPath", getOpenStudioNAMCatalogJson().getFullPathName()); + root->setProperty("manifestPath", getOpenStudioNAMManifestJson().getFullPathName()); + return juce::var(root.get()); +} + +bool namLibraryRecordMatches(juce::DynamicObject* record, int modelId, const juce::String& localPath) +{ + if (record == nullptr) + return false; + + if (modelId > 0 && static_cast(record->getProperty("modelId")) == modelId) + return true; + + return localPath.isNotEmpty() && record->getProperty("localPath").toString() == localPath; +} + +juce::String normaliseNAMArchitectureForCompare(juce::String architecture) +{ + architecture = architecture.trim().toLowerCase(); + if (architecture == "a1") + return "1"; + if (architecture == "a2") + return "2"; + if (architecture == "1" || architecture == "2" || architecture == "custom") + return architecture; + return architecture; +} + +bool nonEmptyNAMMetadataChanged(const juce::String& latestValue, const juce::String& currentValue) +{ + return latestValue.trim().isNotEmpty() && latestValue.trim() != currentValue.trim(); +} + +struct NAMCatalogMatch +{ + juce::var model; + juce::var tone; + bool exactModelId = false; +}; + +NAMCatalogMatch findCatalogModelForInstalledNAMRecord(const juce::var& catalog, juce::DynamicObject* record) +{ + NAMCatalogMatch match; + if (record == nullptr) + return match; + + auto* catalogObject = catalog.getDynamicObject(); + if (catalogObject == nullptr) + return match; + + const int installedModelId = static_cast(record->getProperty("modelId")); + const int installedToneId = static_cast(record->getProperty("toneId")); + const auto installedArchitecture = normaliseNAMArchitectureForCompare(record->getProperty("architecture").toString()); + const auto tonesVar = catalogObject->getProperty("tones"); + auto* tones = tonesVar.getArray(); + if (tones == nullptr) + return match; + + for (auto& toneVar : *tones) + { + auto* tone = toneVar.getDynamicObject(); + if (tone == nullptr) + continue; + + const int toneId = getModelObjectInt(tone, "id", "toneId"); + const auto modelsVar = tone->getProperty("models"); + auto* models = modelsVar.getArray(); + if (models == nullptr) + continue; + + for (auto& modelVar : *models) + { + auto* model = modelVar.getDynamicObject(); + if (model == nullptr) + continue; + + const int modelId = getModelObjectInt(model, "id", "model_id"); + if (installedModelId > 0 && modelId == installedModelId) + { + match.model = modelVar; + match.tone = toneVar; + match.exactModelId = true; + return match; + } + + if (match.model.isVoid() + && installedToneId > 0 + && toneId == installedToneId) + { + const auto modelArchitecture = normaliseNAMArchitectureForCompare(getModelObjectString(model, "architecture_version", "architecture")); + if (installedArchitecture.isEmpty() || modelArchitecture.isEmpty() || installedArchitecture == modelArchitecture) + { + match.model = modelVar; + match.tone = toneVar; + match.exactModelId = false; + } + } + } + } + + return match; +} + +juce::String getNAMCatalogUpdateReason(juce::DynamicObject* record, juce::DynamicObject* latestModel, bool exactModelId) +{ + if (record == nullptr || latestModel == nullptr) + return {}; + + if (! exactModelId) + { + const int latestModelId = getModelObjectInt(latestModel, "id", "model_id"); + const int currentModelId = static_cast(record->getProperty("modelId")); + if (latestModelId > 0 && latestModelId != currentModelId) + return "New catalog model for this tone"; + } + + const auto latestModelUrl = getModelObjectString(latestModel, "model_url", "modelUrl"); + const auto currentModelUrl = record->getProperty("modelUrl").toString(); + if (nonEmptyNAMMetadataChanged(latestModelUrl, currentModelUrl)) + return "Download URL changed"; + + const auto latestChecksum = normaliseNAMSha256(getModelObjectString(latestModel, "sha256", "checksum")); + const auto currentChecksum = normaliseNAMSha256(record->getProperty("checksum").toString()); + if (nonEmptyNAMMetadataChanged(latestChecksum, currentChecksum)) + return "Checksum changed"; + + const auto latestName = getModelObjectString(latestModel, "name", "title"); + const auto currentName = record->getProperty("name").toString(); + if (nonEmptyNAMMetadataChanged(latestName, currentName)) + return "Name changed"; + + const auto latestArchitecture = normaliseNAMArchitectureForCompare(getModelObjectString(latestModel, "architecture_version", "architecture")); + const auto currentArchitecture = normaliseNAMArchitectureForCompare(record->getProperty("architecture").toString()); + if (latestArchitecture.isNotEmpty() && latestArchitecture != currentArchitecture) + return "Architecture changed"; + + return {}; +} + +bool setNAMRecordPropertyIfChanged(juce::DynamicObject& object, const juce::Identifier& property, const juce::var& value) +{ + if (object.getProperty(property) == value) + return false; + + object.setProperty(property, value); + return true; +} + +bool setNAMRecordObjectPropertyIfChanged(juce::DynamicObject& object, const juce::Identifier& property, const juce::var& value) +{ + if (juce::JSON::toString(object.getProperty(property), false) == juce::JSON::toString(value, false)) + return false; + + object.setProperty(property, value); + return true; +} + +bool isFileInsideNAMPreviews(const juce::File& file) +{ + auto previewPath = getOpenStudioNAMRoot().getChildFile("previews").getFullPathName(); + if (!previewPath.endsWithChar(juce::File::getSeparatorChar())) + previewPath << juce::File::getSeparatorString(); + + return file.getFullPathName().startsWithIgnoreCase(previewPath); +} + +bool areNAMPathsEquivalent(const juce::String& leftPath, const juce::String& rightPath) +{ + if (leftPath.isEmpty() || rightPath.isEmpty()) + return false; + + auto normalise = [] (const juce::String& path) + { + auto normalised = juce::File(path).getFullPathName().replaceCharacter('\\', '/'); + while (normalised.length() > 1 && normalised.endsWithChar('/')) + normalised = normalised.dropLastCharacters(1); +#if JUCE_WINDOWS + return normalised.toLowerCase(); +#else + return normalised; +#endif + }; + + return normalise(leftPath) == normalise(rightPath); +} + +struct NAMLibraryManifestReadResult +{ + bool success = false; + juce::var manifest; + juce::String error; +}; + +juce::var makeEmptyNAMLibraryManifest() +{ + juce::DynamicObject::Ptr object = new juce::DynamicObject(); + object->setProperty("schemaVersion", 1); + object->setProperty("installed", juce::Array()); + return juce::var(object.get()); +} + +NAMLibraryManifestReadResult readNAMLibraryManifestStrictLocked( + const juce::File& manifestFile) +{ + NAMLibraryManifestReadResult result; + if (! manifestFile.existsAsFile()) + { + result.success = true; + result.manifest = makeEmptyNAMLibraryManifest(); + return result; + } + + result.manifest = juce::JSON::parse(manifestFile); + auto* object = result.manifest.getDynamicObject(); + if (object == nullptr + || object->getProperty("installed").getArray() == nullptr) + { + result.error = + "The existing NAM library manifest is invalid. OpenStudio left it unchanged instead of replacing installed-model records."; + return result; + } + + result.success = true; + return result; +} + +constexpr const char* kNAMLibraryTransactionMarkerName = + ".library_asset_transaction.json"; +constexpr const char* kNAMLibraryRollbackTag = + ".openstudio-nam-rollback-"; + +struct NAMLibraryTransactionRecoveryResult +{ + bool success = true; + bool markerFound = false; + bool committed = false; + bool rolledBack = false; + juce::String error; +}; + +enum class NAMLibraryPublicationFailurePoint +{ + none, + manifestPublish, + afterAssetPublish, + afterManifestPublish +}; + +struct NAMLibraryPublicationTestHooks +{ + NAMLibraryPublicationFailurePoint failurePoint = + NAMLibraryPublicationFailurePoint::none; +}; + +struct NAMLibraryPublicationResult +{ + bool success = false; + bool recoveryAttempted = false; + bool recoverySucceeded = false; + bool recoveryPending = false; + bool committedDuringRecovery = false; + juce::String error; +}; + +bool calculateUncancelledFileSha256(const juce::File& file, + juce::String& sha256) +{ + sha256.clear(); + juce::FileInputStream input(file); + if (! input.openedOk()) + return false; + sha256 = juce::SHA256(input).toHexString().toLowerCase(); + return sha256.length() == 64; +} + +bool removeFileAndVerify(const juce::File& file) +{ + return ! file.existsAsFile() + || (file.deleteFile() && ! file.existsAsFile()); +} + +bool NAMFileMatchesSnapshot(const juce::File& file, + juce::int64 expectedSize, + juce::int64 expectedModificationTimeMs, + bool requireModificationTime) +{ + if (! file.existsAsFile() || file.getSize() != expectedSize) + return false; + return ! requireModificationTime + || file.getLastModificationTime().toMilliseconds() + == expectedModificationTimeMs; +} + +bool isSafeNAMTransactionRelativePath(const juce::String& path) +{ + const auto portable = path.trim().replaceCharacter('\\', '/'); + return portable.isNotEmpty() + && ! juce::File::isAbsolutePath(portable) + && portable != ".." + && ! portable.startsWith("../") + && ! portable.contains("/../") + && ! portable.endsWith("/.."); +} + +NAMLibraryTransactionRecoveryResult +recoverPendingNAMLibraryTransactionLocked( + const juce::File& namRoot, + const juce::File& manifestFile) +{ + NAMLibraryTransactionRecoveryResult result; + const auto markerFile = namRoot.getChildFile( + kNAMLibraryTransactionMarkerName); + if (! markerFile.existsAsFile()) + return result; + + result.markerFound = true; + const auto markerValue = juce::JSON::parse(markerFile); + auto* marker = markerValue.getDynamicObject(); + if (marker == nullptr + || static_cast(marker->getProperty("schemaVersion")) != 1) + { + result.success = false; + result.error = + "The pending NAM library transaction marker is invalid; it was retained for manual recovery."; + return result; + } + + const auto transactionId = + marker->getProperty("transactionId").toString().trim(); + const auto targetRelativePath = + marker->getProperty("targetRelativePath").toString(); + const auto rollbackRelativePath = + marker->getProperty("rollbackRelativePath").toString(); + const auto expectedManifestSha256 = normaliseNAMSha256( + marker->getProperty("expectedManifestSha256").toString()); + const bool hadOriginal = + static_cast(marker->getProperty("hadOriginal")); + const auto oldSize = static_cast( + static_cast(marker->getProperty("oldSizeBytes"))); + const auto oldModificationTimeMs = static_cast( + static_cast(marker->getProperty( + "oldModificationTimeMs"))); + const auto newSize = static_cast( + static_cast(marker->getProperty("newSizeBytes"))); + const auto newModificationTimeMs = static_cast( + static_cast(marker->getProperty( + "newModificationTimeMs"))); + const auto expectedAssetSha256 = normaliseNAMSha256( + marker->getProperty("expectedAssetSha256").toString()); + const auto libraryRoot = namRoot.getChildFile("library"); + const auto targetFile = libraryRoot.getChildFile(targetRelativePath); + const auto rollbackFile = libraryRoot.getChildFile( + rollbackRelativePath); + const auto expectedRollbackName = "." + targetFile.getFileName() + + kNAMLibraryRollbackTag + transactionId; + const bool safeMarker = markerFile.getParentDirectory() == namRoot + && markerFile.getFileName() + == kNAMLibraryTransactionMarkerName + && transactionId.isNotEmpty() + && transactionId.length() <= 64 + && transactionId.containsOnly( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-") + && isSafeNAMTransactionRelativePath(targetRelativePath) + && isSafeNAMTransactionRelativePath(rollbackRelativePath) + && targetFile.isAChildOf(libraryRoot) + && rollbackFile.isAChildOf(libraryRoot) + && rollbackFile.getParentDirectory() + == targetFile.getParentDirectory() + && rollbackFile.getFileName() == expectedRollbackName + && expectedManifestSha256.length() == 64 + && expectedManifestSha256.containsOnly( + "0123456789abcdef") + && expectedAssetSha256.length() == 64 + && expectedAssetSha256.containsOnly( + "0123456789abcdef") + && newSize > 0 + && (! hadOriginal || oldSize >= 0); + if (! safeMarker) + { + result.success = false; + result.error = + "The pending NAM library transaction marker contains unsafe paths or invalid metadata; it was retained without touching any asset."; + return result; + } + + juce::String currentManifestSha256; + const bool manifestDigestAvailable = + manifestFile.existsAsFile() + && calculateUncancelledFileSha256( + manifestFile, currentManifestSha256); + result.committed = manifestDigestAvailable + && currentManifestSha256 == expectedManifestSha256; + + if (result.committed) + { + if (! NAMFileMatchesSnapshot( + targetFile, newSize, newModificationTimeMs, false)) + { + result.success = false; + result.error = + "The NAM manifest was committed, but the published asset no longer matches the transaction marker. The rollback and marker were retained."; + return result; + } + if (! removeFileAndVerify(rollbackFile) + || ! removeFileAndVerify(markerFile)) + { + result.success = false; + result.error = + "The NAM asset and manifest were committed, but transaction cleanup is still pending."; + return result; + } + return result; + } + + if (hadOriginal) + { + if (rollbackFile.existsAsFile()) + { + if (rollbackFile.getSize() != oldSize) + { + result.success = false; + result.error = + "The NAM rollback file does not match the original asset size; recovery stopped without deleting it."; + return result; + } + + juce::TemporaryFile restoredTarget( + targetFile, juce::TemporaryFile::useHiddenFile); + if (! rollbackFile.copyFileTo(restoredTarget.getFile()) + || restoredTarget.getFile().getSize() != oldSize + || ! restoredTarget.overwriteTargetFileWithTemporary() + || ! NAMFileMatchesSnapshot( + targetFile, oldSize, oldModificationTimeMs, false)) + { + result.success = false; + result.error = + "OpenStudio could not restore the prior NAM asset. The rollback file and transaction marker were retained."; + return result; + } + } + else if (! NAMFileMatchesSnapshot( + targetFile, + oldSize, + oldModificationTimeMs, + true)) + { + result.success = false; + result.error = + "The prior NAM asset and its rollback file are both unavailable. The transaction marker was retained."; + return result; + } + + result.rolledBack = true; + } + else + { + if (targetFile.existsAsFile() + && ! NAMFileMatchesSnapshot( + targetFile, + newSize, + newModificationTimeMs, + true)) + { + result.success = false; + result.error = + "The uncommitted NAM target has changed since publication. It was retained with the transaction marker instead of being deleted."; + return result; + } + if (! removeFileAndVerify(targetFile)) + { + result.success = false; + result.error = + "OpenStudio could not remove the uncommitted NAM asset. The transaction marker was retained."; + return result; + } + result.rolledBack = true; + } + + if (! removeFileAndVerify(rollbackFile) + || ! removeFileAndVerify(markerFile)) + { + result.success = false; + result.error = + "The NAM file state was recovered, but transaction cleanup is still pending."; + } + return result; +} + +NAMLibraryPublicationResult publishNAMLibraryAssetAndManifestLocked( + const juce::File& stagedAsset, + const juce::File& targetFile, + const juce::File& namRoot, + const juce::File& manifestFile, + const juce::var& manifest, + const juce::String& expectedAssetSha256, + const NAMLibraryPublicationTestHooks* testHooks = nullptr) +{ + NAMLibraryPublicationResult result; + const auto libraryRoot = namRoot.getChildFile("library"); + const auto markerFile = namRoot.getChildFile( + kNAMLibraryTransactionMarkerName); + if (! stagedAsset.existsAsFile() + || stagedAsset.getSize() <= 0 + || ! targetFile.isAChildOf(libraryRoot) + || targetFile.getParentDirectory() + != stagedAsset.getParentDirectory()) + { + result.error = + "The prepared NAM publication paths are invalid."; + return result; + } + + const auto priorRecovery = + recoverPendingNAMLibraryTransactionLocked( + namRoot, manifestFile); + if (! priorRecovery.success) + { + result.recoveryAttempted = priorRecovery.markerFound; + result.recoveryPending = priorRecovery.markerFound; + result.error = priorRecovery.error; + return result; + } + + juce::TemporaryFile stagedManifest( + manifestFile, juce::TemporaryFile::useHiddenFile); + if (! stagedManifest.getFile().replaceWithText( + juce::JSON::toString(manifest, true))) + { + result.error = + "Could not prepare the updated NAM library manifest."; + return result; + } + juce::String expectedManifestSha256; + if (! calculateUncancelledFileSha256( + stagedManifest.getFile(), expectedManifestSha256)) + { + result.error = + "Could not verify the prepared NAM library manifest."; + return result; + } + + const auto transactionId = juce::Uuid().toString(); + const auto rollbackFile = targetFile.getSiblingFile( + "." + targetFile.getFileName() + kNAMLibraryRollbackTag + + transactionId); + const bool hadOriginal = targetFile.existsAsFile(); + const auto oldSize = hadOriginal ? targetFile.getSize() : 0; + const auto oldModificationTimeMs = hadOriginal + ? targetFile.getLastModificationTime().toMilliseconds() + : 0; + const auto newSize = stagedAsset.getSize(); + const auto newModificationTimeMs = + stagedAsset.getLastModificationTime().toMilliseconds(); + + juce::DynamicObject::Ptr marker = new juce::DynamicObject(); + marker->setProperty("schemaVersion", 1); + marker->setProperty("transactionId", transactionId); + marker->setProperty( + "targetRelativePath", + targetFile.getRelativePathFrom(libraryRoot)); + marker->setProperty( + "rollbackRelativePath", + rollbackFile.getRelativePathFrom(libraryRoot)); + marker->setProperty("hadOriginal", hadOriginal); + marker->setProperty("oldSizeBytes", static_cast(oldSize)); + marker->setProperty( + "oldModificationTimeMs", + static_cast(oldModificationTimeMs)); + marker->setProperty("newSizeBytes", static_cast(newSize)); + marker->setProperty( + "newModificationTimeMs", + static_cast(newModificationTimeMs)); + marker->setProperty( + "expectedAssetSha256", + normaliseNAMSha256(expectedAssetSha256)); + marker->setProperty( + "expectedManifestSha256", expectedManifestSha256); + marker->setProperty( + "createdAt", juce::Time::getCurrentTime().toISO8601(true)); + if (! persistJsonFileAtomically( + markerFile, juce::var(marker.get()))) + { + result.error = + "Could not persist the NAM library transaction marker."; + return result; + } + + const auto recoverAfterFailure = [&] + (const juce::String& failureMessage) + { + result.recoveryAttempted = true; + const auto recovery = + recoverPendingNAMLibraryTransactionLocked( + namRoot, manifestFile); + result.recoverySucceeded = recovery.success; + result.recoveryPending = ! recovery.success; + result.committedDuringRecovery = recovery.committed; + if (recovery.committed && recovery.success) + { + result.success = true; + return; + } + result.error = failureMessage; + if (recovery.success && recovery.rolledBack) + result.error += " The previous NAM file state was restored."; + else if (! recovery.error.isEmpty()) + result.error += " " + recovery.error; + }; + + if (hadOriginal + && (! targetFile.moveFileTo(rollbackFile) + || targetFile.existsAsFile() + || ! rollbackFile.existsAsFile() + || rollbackFile.getSize() != oldSize)) + { + recoverAfterFailure( + "Could not create a verified rollback copy of the existing NAM asset."); + return result; + } + + if (! stagedAsset.moveFileTo(targetFile) + || ! NAMFileMatchesSnapshot( + targetFile, newSize, newModificationTimeMs, false)) + { + recoverAfterFailure( + "Could not publish the verified NAM asset into the library."); + return result; + } + + if (testHooks != nullptr + && testHooks->failurePoint + == NAMLibraryPublicationFailurePoint::afterAssetPublish) + { + result.recoveryPending = true; + result.error = + "Injected interruption after NAM asset publication."; + return result; + } + + const bool injectManifestFailure = testHooks != nullptr + && testHooks->failurePoint + == NAMLibraryPublicationFailurePoint::manifestPublish; + if (injectManifestFailure + || ! stagedManifest.overwriteTargetFileWithTemporary()) + { + recoverAfterFailure( + "Could not persist the NAM library manifest after publishing the asset."); + return result; + } + + if (testHooks != nullptr + && testHooks->failurePoint + == NAMLibraryPublicationFailurePoint::afterManifestPublish) + { + result.recoveryPending = true; + result.error = + "Injected interruption after NAM manifest publication."; + return result; + } + + const auto cleanup = recoverPendingNAMLibraryTransactionLocked( + namRoot, manifestFile); + result.success = cleanup.committed; + result.recoveryAttempted = true; + result.recoverySucceeded = cleanup.success; + result.recoveryPending = ! cleanup.success; + result.committedDuringRecovery = cleanup.committed; + if (! result.success) + result.error = cleanup.error.isNotEmpty() + ? cleanup.error + : juce::String( + "The NAM asset publication could not be verified."); + else if (! cleanup.success) + result.error = cleanup.error; + return result; +} + +void applyNAMLibraryRecoveryStatus( + juce::DynamicObject& object, + const NAMLibraryTransactionRecoveryResult& recovery) +{ + if (! recovery.markerFound) + return; + object.setProperty( + "transactionRecoveryStatus", + recovery.success + ? (recovery.committed ? "finalized" : "rolledBack") + : "pending"); + object.setProperty("transactionRecoveryPending", ! recovery.success); + if (! recovery.error.isEmpty()) + object.setProperty("transactionRecoveryError", recovery.error); +} + +void applyNAMLibraryPublicationStatus( + juce::DynamicObject& object, + const NAMLibraryPublicationResult& publication) +{ + object.setProperty( + "transactionRecoveryAttempted", + publication.recoveryAttempted); + object.setProperty( + "transactionRecoverySucceeded", + publication.recoverySucceeded); + object.setProperty( + "transactionRecoveryPending", + publication.recoveryPending); + object.setProperty( + "transactionCommittedDuringRecovery", + publication.committedDuringRecovery); + if (! publication.error.isEmpty()) + object.setProperty("transactionRecoveryDetail", publication.error); +} + +juce::var makeNAMPreviewRetentionResult(const juce::String& warning, + const juce::String& loadedSlot = {}) +{ + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", true); + result->setProperty("deleteSkipped", true); + result->setProperty("retained", true); + result->setProperty("recoverable", true); + result->setProperty("warning", warning); + if (loadedSlot.isNotEmpty()) + { + result->setProperty("inUse", true); + result->setProperty("loadedSlot", loadedSlot); + } + return juce::var(result.get()); +} + +struct NAMLibraryHashCandidate +{ + juce::String localPath; + juce::int64 fileSize = 0; + juce::int64 modificationTimeMs = 0; +}; + +struct NAMLibraryHashObservation : NAMLibraryHashCandidate +{ + bool success = false; + juce::String sha256; +}; + +using NAMLibraryHashFunction = std::function; + +juce::var makeNAMLibraryManifestFailure(const juce::String& error) +{ + auto manifest = makeEmptyNAMLibraryManifest(); + if (auto* object = manifest.getDynamicObject()) + { + object->setProperty("success", false); + object->setProperty("error", error); + } + return manifest; +} + +std::vector +collectNAMLibraryHashCandidates(const juce::var& manifest) +{ + std::vector candidates; + auto* object = manifest.getDynamicObject(); + auto* installed = object != nullptr + ? object->getProperty("installed").getArray() + : nullptr; + if (installed == nullptr) + return candidates; + + for (const auto& recordValue : *installed) + { + auto* record = recordValue.getDynamicObject(); + if (record == nullptr) + continue; + const auto existingSha256 = normaliseNAMSha256( + record->getProperty("fileSha256").toString()); + if (existingSha256.length() == 64 + && existingSha256.containsOnly("0123456789abcdef")) + { + continue; + } + + const auto path = record->getProperty("localPath").toString(); + const juce::File file(path); + if (path.isEmpty() || ! file.existsAsFile()) + continue; + const NAMLibraryHashCandidate candidate { + file.getFullPathName(), + file.getSize(), + file.getLastModificationTime().toMilliseconds() + }; + const auto duplicate = std::find_if( + candidates.begin(), candidates.end(), + [&candidate] (const NAMLibraryHashCandidate& existing) + { + return areNAMPathsEquivalent( + existing.localPath, candidate.localPath) + && existing.fileSize == candidate.fileSize + && existing.modificationTimeMs + == candidate.modificationTimeMs; + }); + if (duplicate == candidates.end()) + candidates.push_back(candidate); + } + return candidates; +} + +juce::var refreshNAMLibraryManifestLockedAtPaths( + const juce::File& namRoot, + const juce::File& manifestFile, + const juce::File& catalogFile, + bool persistIfChanged, + const std::vector& hashObservations, + NAMLibraryTransactionRecoveryResult* recoveryOutput = nullptr) +{ + const auto recovery = recoverPendingNAMLibraryTransactionLocked( + namRoot, manifestFile); + if (recoveryOutput != nullptr) + *recoveryOutput = recovery; + if (! recovery.success) + return makeNAMLibraryManifestFailure(recovery.error); + + const auto readResult = readNAMLibraryManifestStrictLocked( + manifestFile); + if (! readResult.success) + return makeNAMLibraryManifestFailure(readResult.error); + + auto manifest = readResult.manifest; + auto* manifestObject = manifest.getDynamicObject(); + if (manifestObject == nullptr) + return manifest; + + const auto nowIso = juce::Time::getCurrentTime().toISO8601(true); + const auto catalog = parseJsonFileOrDefault(catalogFile, "tones"); + const auto catalogGeneratedAt = catalog.getDynamicObject() != nullptr + ? catalog.getDynamicObject()->getProperty("generatedAt").toString() + : juce::String(); + bool changed = false; + const auto installedVar = manifestObject->getProperty("installed"); + if (auto* installed = installedVar.getArray()) + { + for (auto& recordVar : *installed) + { + if (auto* record = recordVar.getDynamicObject()) + { + bool recordChanged = false; + const auto localPath = record->getProperty("localPath").toString(); + const juce::File localFile(localPath); + const bool missing = localPath.isEmpty() || !localFile.existsAsFile(); + recordChanged = setNAMRecordPropertyIfChanged(*record, "missing", missing) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "fileSizeBytes", missing ? juce::var() : juce::var(static_cast(localFile.getSize()))) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "missingSince", missing + ? (record->getProperty("missingSince").toString().isNotEmpty() ? record->getProperty("missingSince") : juce::var(nowIso)) + : juce::var()) || recordChanged; + + auto fileSha256 = normaliseNAMSha256(record->getProperty("fileSha256").toString()); + const bool validFileSha256 = fileSha256.length() == 64 + && fileSha256.containsOnly("0123456789abcdef"); + if (! missing && ! validFileSha256) + { + const auto currentSize = localFile.getSize(); + const auto currentModificationTimeMs = + localFile.getLastModificationTime().toMilliseconds(); + const auto observation = std::find_if( + hashObservations.begin(), + hashObservations.end(), + [&] (const NAMLibraryHashObservation& candidate) + { + return candidate.success + && areNAMPathsEquivalent( + candidate.localPath, localPath) + && candidate.fileSize == currentSize + && candidate.modificationTimeMs + == currentModificationTimeMs; + }); + if (observation != hashObservations.end()) + { + fileSha256 = observation->sha256; + recordChanged = setNAMRecordPropertyIfChanged( + *record, + "fileSha256", + fileSha256) || recordChanged; + } + } + + juce::String assetId; + if (fileSha256.length() == 64 && fileSha256.containsOnly("0123456789abcdef")) + { + assetId = "sha256:" + fileSha256; + } + else + { + const int modelId = static_cast(record->getProperty("modelId")); + if (modelId > 0) + { + auto provider = record->getProperty("sourceProvider").toString().trim().toLowerCase(); + if (provider.isEmpty()) + provider = "tone3000"; + assetId = provider + ":model:" + juce::String(modelId); + } + } + recordChanged = setNAMRecordPropertyIfChanged(*record, "assetId", assetId) || recordChanged; + + if (!record->hasProperty("favorite")) + { + record->setProperty("favorite", false); + recordChanged = true; + } + + if (!record->hasProperty("sourceProvider")) + { + record->setProperty("sourceProvider", record->getProperty("source").toString().isNotEmpty() + ? record->getProperty("source") + : juce::var("tone3000")); + recordChanged = true; + } + + const auto catalogMatch = findCatalogModelForInstalledNAMRecord(catalog, record); + auto* latestModel = catalogMatch.model.getDynamicObject(); + auto* latestTone = catalogMatch.tone.getDynamicObject(); + const auto updateReason = getNAMCatalogUpdateReason(record, latestModel, catalogMatch.exactModelId); + const bool updateAvailable = updateReason.isNotEmpty(); + recordChanged = setNAMRecordPropertyIfChanged(*record, "updateAvailable", updateAvailable) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "updateReason", updateReason) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "catalogSeenAt", catalogGeneratedAt) || recordChanged; + + if (latestModel != nullptr) + { + const int latestModelId = getModelObjectInt(latestModel, "id", "model_id"); + const int latestToneId = latestTone != nullptr ? getModelObjectInt(latestTone, "id", "toneId") : static_cast(record->getProperty("toneId")); + const auto latestModelUrl = getModelObjectString(latestModel, "model_url", "modelUrl"); + recordChanged = setNAMRecordPropertyIfChanged(*record, "latestModelId", latestModelId) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "latestToneId", latestToneId) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "latestModelUrl", latestModelUrl) || recordChanged; + recordChanged = setNAMRecordObjectPropertyIfChanged(*record, "latestMetadata", catalogMatch.model) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "latestMatchExact", catalogMatch.exactModelId) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "lastSeenAt", catalogGeneratedAt.isNotEmpty() ? juce::var(catalogGeneratedAt) : juce::var(nowIso)) || recordChanged; + } + else + { + recordChanged = setNAMRecordPropertyIfChanged(*record, "latestModelId", 0) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "latestToneId", 0) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "latestModelUrl", juce::var()) || recordChanged; + recordChanged = setNAMRecordObjectPropertyIfChanged(*record, "latestMetadata", juce::var()) || recordChanged; + recordChanged = setNAMRecordPropertyIfChanged(*record, "latestMatchExact", false) || recordChanged; + } + + if (recordChanged) + record->setProperty("manifestUpdatedAt", nowIso); + + changed = recordChanged || changed; + } + } + } + + if (changed && persistIfChanged + && ! persistNAMLibraryManifestLocked(manifestFile, manifest)) + { + juce::Logger::writeToLog( + "NAM library: could not atomically persist the refreshed manifest"); + manifestObject->setProperty("success", false); + manifestObject->setProperty( + "error", + "Could not persist the refreshed NAM library manifest."); + } + + return manifest; +} + +juce::var refreshNAMLibraryManifestLocked(bool persistIfChanged) +{ + static const std::vector noHashes; + return refreshNAMLibraryManifestLockedAtPaths( + getOpenStudioNAMRoot(), + getOpenStudioNAMManifestJson(), + getOpenStudioNAMCatalogJson(), + persistIfChanged, + noHashes); +} + +juce::var refreshNAMLibraryManifestAtPaths( + const juce::File& namRoot, + const juce::File& manifestFile, + const juce::File& catalogFile, + bool persistIfChanged, + const NAMLibraryHashFunction& hashFunction) +{ + std::vector candidates; + NAMLibraryTransactionRecoveryResult initialRecovery; + { + const ScopedNAMLibraryMutationLock manifestLock; + if (! manifestLock.locked()) + return makeNAMLibraryLockFailure(manifestFile); + + initialRecovery = recoverPendingNAMLibraryTransactionLocked( + namRoot, manifestFile); + if (! initialRecovery.success) + return makeNAMLibraryManifestFailure( + initialRecovery.error); + const auto readResult = readNAMLibraryManifestStrictLocked( + manifestFile); + if (! readResult.success) + return makeNAMLibraryManifestFailure(readResult.error); + candidates = collectNAMLibraryHashCandidates( + readResult.manifest); + } + + std::vector observations; + observations.reserve(candidates.size()); + for (const auto& candidate : candidates) + { + if (isTone3000TaskCancelled()) + return makeNAMLibraryManifestFailure( + "The NAM library refresh was canceled while checking installed assets."); + NAMLibraryHashObservation observation; + observation.localPath = candidate.localPath; + observation.fileSize = candidate.fileSize; + observation.modificationTimeMs = + candidate.modificationTimeMs; + juce::String error; + observation.success = hashFunction( + juce::File(candidate.localPath), + observation.sha256, + error); + observation.sha256 = normaliseNAMSha256( + observation.sha256); + observation.success = observation.success + && observation.sha256.length() == 64 + && observation.sha256.containsOnly( + "0123456789abcdef"); + observations.push_back(observation); + } + + const ScopedNAMLibraryMutationLock manifestLock; + if (! manifestLock.locked()) + return makeNAMLibraryLockFailure(manifestFile); + NAMLibraryTransactionRecoveryResult finalRecovery; + auto manifest = refreshNAMLibraryManifestLockedAtPaths( + namRoot, + manifestFile, + catalogFile, + persistIfChanged, + observations, + &finalRecovery); + if (auto* object = manifest.getDynamicObject()) + { + applyNAMLibraryRecoveryStatus( + *object, + finalRecovery.markerFound + ? finalRecovery + : initialRecovery); + } + return manifest; +} + +juce::var refreshNAMLibraryManifest(bool persistIfChanged) +{ + return refreshNAMLibraryManifestAtPaths( + getOpenStudioNAMRoot(), + getOpenStudioNAMManifestJson(), + getOpenStudioNAMCatalogJson(), + persistIfChanged, + [] (const juce::File& file, + juce::String& sha256, + juce::String& error) + { + return calculateNAMAssetSha256(file, sha256, error); + }); +} + +juce::var setNAMLibraryFavorite(int modelId, const juce::String& localPath, bool favorite) +{ + const ScopedNAMLibraryMutationLock manifestLock; + if (! manifestLock.locked()) + return makeNAMLibraryLockFailure(getOpenStudioNAMManifestJson()); + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + + auto manifest = refreshNAMLibraryManifestLocked(false); + const auto manifestError = manifest.getProperty( + "error", {}).toString(); + if (manifestError.isNotEmpty()) + { + result->setProperty("error", manifestError); + return juce::var(result.get()); + } + auto* manifestObject = manifest.getDynamicObject(); + const auto installedVar = manifestObject != nullptr ? manifestObject->getProperty("installed") : juce::var(); + auto* installed = installedVar.getArray(); + if (installed == nullptr) + { + result->setProperty("error", "NAM library manifest is invalid"); + return juce::var(result.get()); + } + + for (auto& recordVar : *installed) + { + if (auto* record = recordVar.getDynamicObject()) + { + if (namLibraryRecordMatches(record, modelId, localPath)) + { + const auto nowIso = juce::Time::getCurrentTime().toISO8601(true); + record->setProperty("favorite", favorite); + record->setProperty("updatedAt", nowIso); + record->setProperty("manifestUpdatedAt", nowIso); + if (! persistNAMLibraryManifestLocked( + getOpenStudioNAMManifestJson(), manifest)) + { + result->setProperty("error", "Could not persist the NAM library manifest"); + return juce::var(result.get()); + } + result->setProperty("success", true); + result->setProperty("record", recordVar); + return juce::var(result.get()); + } + } + } + + result->setProperty("error", "NAM model is not installed"); + return juce::var(result.get()); +} + +juce::var removeNAMModelFromLibrary(int modelId, const juce::String& localPath, bool deleteLocalFile) +{ + // Use the same ordering as preview discard: rack state first, then library. + // Physical deletion remains disabled until the engine can prove that no rack, + // preset, or project references the path. + const juce::ScopedLock stateLock(namModelMutationStateLock); + const ScopedNAMLibraryMutationLock manifestLock; + if (! manifestLock.locked()) + return makeNAMLibraryLockFailure(getOpenStudioNAMManifestJson()); + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + + auto manifest = refreshNAMLibraryManifestLocked(false); + const auto manifestError = manifest.getProperty( + "error", {}).toString(); + if (manifestError.isNotEmpty()) + { + result->setProperty("error", manifestError); + return juce::var(result.get()); + } + auto* manifestObject = manifest.getDynamicObject(); + const auto installedVar = manifestObject != nullptr ? manifestObject->getProperty("installed") : juce::var(); + auto* installedArray = installedVar.getArray(); + if (installedArray == nullptr) + { + result->setProperty("error", "NAM library manifest is invalid"); + return juce::var(result.get()); + } + + juce::Array kept; + int removed = 0; + const bool deletedFile = false; + bool deleteSkipped = false; + const bool deleteFailed = false; + + for (const auto& recordVar : *installedArray) + { + auto* record = recordVar.getDynamicObject(); + if (!namLibraryRecordMatches(record, modelId, localPath)) + { + kept.add(recordVar); + continue; + } + + ++removed; + if (deleteLocalFile && record != nullptr) + { + const auto path = record->getProperty("localPath").toString(); + const juce::File file(path); + // Removing the manifest entry is safe, but physical deletion is not: + // another rack, preset, or project may still reference this path. + if (file.existsAsFile()) + deleteSkipped = true; + } + } + + if (removed <= 0) + { + result->setProperty("error", "NAM model is not installed"); + return juce::var(result.get()); + } + + manifestObject->setProperty("installed", kept); + if (! persistNAMLibraryManifestLocked( + getOpenStudioNAMManifestJson(), manifest)) + { + result->setProperty("error", "Could not persist the NAM library manifest"); + return juce::var(result.get()); + } + result->setProperty("success", true); + result->setProperty("removed", removed); + result->setProperty("deletedFile", deletedFile); + result->setProperty("deleteSkipped", deleteSkipped); + result->setProperty("deleteFailed", deleteFailed); + if (deleteSkipped) + { + result->setProperty("retained", true); + result->setProperty("recoverable", true); + result->setProperty("warning", + "The NAM file was retained because host-wide rack references cannot yet be proven clear"); + } + return juce::var(result.get()); +} + +juce::var installNAMModelFromMetadata(juce::var modelPayload, bool previewMode = false) +{ + if (modelPayload.isString()) + modelPayload = juce::JSON::parse(modelPayload.toString()); + + auto* model = modelPayload.getDynamicObject(); + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + if (model == nullptr) + { + result->setProperty("error", "Invalid NAM model metadata"); + return juce::var(result.get()); + } + + const auto modelUrl = getModelObjectString(model, "model_url", "modelUrl"); + if (modelUrl.isEmpty()) + { + result->setProperty("error", "Model metadata does not include model_url"); + return juce::var(result.get()); + } + + const int modelId = getModelObjectInt(model, "id", "model_id"); + const int toneId = getModelObjectInt(model, "tone_id", "toneId"); + const auto displayName = getModelObjectString(model, "name", "title"); + const auto architecture = getModelObjectString(model, "architecture_version", "architecture"); + const auto sourceUrl = getModelObjectString(model, "source_url", "sourceUrl"); + const auto license = getModelObjectString(model, "license_name", "license"); + const auto creator = getModelObjectString(model, "creator_name", "creator"); + const auto gearType = getModelObjectString(model, "gear_type", "gearType"); + const auto toneTitle = getModelObjectString(model, "tone_title", "toneTitle"); + const auto checksum = getModelObjectString(model, "sha256", "checksum"); + const auto downloadExtension = getTone3000DownloadExtension(modelUrl); + const auto gearTypeLower = gearType.toLowerCase(); + const bool looksLikeCabIR = gearTypeLower.contains("ir") + || gearTypeLower.contains("cab") + || isTone3000AudioIRExtension(downloadExtension); + const auto fileStem = sanitizeNAMFileName( + "tone-" + juce::String(toneId > 0 ? toneId : 0) + + "-model-" + juce::String(modelId > 0 ? modelId : 0) + + "-" + (displayName.isNotEmpty() ? displayName : "nam")); + const auto toneFolderName = "tone-" + juce::String(toneId > 0 ? toneId : 0); + const auto targetDir = previewMode + ? getOpenStudioNAMRoot().getChildFile("previews").getChildFile(toneFolderName) + : getOpenStudioNAMRoot().getChildFile("library").getChildFile(toneFolderName); + const auto directoryResult = targetDir.createDirectory(); + if (directoryResult.failed()) + { + result->setProperty("error", "Could not create the NAM download directory: " + directoryResult.getErrorMessage()); + return juce::var(result.get()); + } + + auto targetFile = targetDir.getChildFile(fileStem); + if (targetFile.getFileExtension().isEmpty()) + targetFile = targetFile.withFileExtension(looksLikeCabIR && isTone3000AudioIRExtension(downloadExtension) ? downloadExtension : ".nam"); + const auto libraryFileName = targetFile.getFileName(); + if (previewMode) + { + const auto previewSuffix = "-preview-" + juce::Uuid().toString().substring(0, 12); + targetFile = targetFile.getSiblingFile( + targetFile.getFileNameWithoutExtension() + previewSuffix + targetFile.getFileExtension()); + } + + const auto token = getStoredTone3000AccessToken(); + if (token.isEmpty()) + { + result->setProperty("error", "Missing TONE3000 access token. Connect TONE3000 first."); + return juce::var(result.get()); + } + + auto download = openAuthenticatedNAMDownload(modelUrl, token); + + if (download.input == nullptr || download.statusCode >= 400) + { + auto error = download.error; + if (error.isEmpty()) + error = "Download failed" + (download.statusCode > 0 + ? " (HTTP " + juce::String(download.statusCode) + ")" + : juce::String()); + result->setProperty("error", error); + return juce::var(result.get()); + } + + juce::TemporaryFile temporaryDownload(targetFile, juce::TemporaryFile::useHiddenFile); + const auto& downloadedFile = temporaryDownload.getFile(); + { + juce::FileOutputStream output(downloadedFile); + if (! output.openedOk()) + { + result->setProperty("error", "Could not create a temporary NAM download file"); + return juce::var(result.get()); + } + + const juce::int64 maxDownloadBytes = looksLikeCabIR + ? 1024LL * 1024LL * 1024LL + : OpenStudioNAMModelSafety::maximumFileBytes; + const auto maximumDownloadDescription = looksLikeCabIR + ? juce::String("1 GiB") + : juce::String(OpenStudioNAMModelSafety::maximumFileDescription); + const auto declaredLength = download.input->getTotalLength(); + if (declaredLength > maxDownloadBytes) + { + result->setProperty( + "error", + "The download exceeds the " + maximumDownloadDescription + + " safety limit"); + return juce::var(result.get()); + } + + std::array buffer {}; + juce::int64 bytesWritten = 0; + while (! download.input->isExhausted()) + { + if (isTone3000TaskCancelled()) + { + result->setProperty( + "error", "The NAM model download was canceled"); + return juce::var(result.get()); + } + const auto bytesRead = download.input->read(buffer.data(), static_cast(buffer.size())); + if (bytesRead <= 0) + break; + if (bytesWritten + bytesRead > maxDownloadBytes) + { + result->setProperty( + "error", + "The download exceeds the " + maximumDownloadDescription + + " safety limit"); + return juce::var(result.get()); + } + if (! output.write(buffer.data(), static_cast(bytesRead))) + { + result->setProperty("error", "Could not write the temporary NAM download file"); + return juce::var(result.get()); + } + bytesWritten += bytesRead; + } + output.flush(); + if (bytesWritten <= 0 + || (declaredLength >= 0 && bytesWritten != declaredLength) + || output.getStatus().failed()) + { + result->setProperty("error", "The NAM model download was incomplete"); + return juce::var(result.get()); + } + } + + if (! downloadedFile.existsAsFile() || downloadedFile.getSize() <= 0) + { + result->setProperty("error", "Downloaded file was empty"); + return juce::var(result.get()); + } + + juce::String actualSha256; + juce::String checksumError; + if (isTone3000TaskCancelled()) + { + result->setProperty( + "error", "The NAM model download was canceled"); + return juce::var(result.get()); + } + if (! verifyNAMFileSha256(downloadedFile, checksum, actualSha256, checksumError)) + { + result->setProperty("error", checksumError + " The temporary download was discarded and was not installed."); + return juce::var(result.get()); + } + if (isTone3000TaskCancelled()) + { + result->setProperty( + "error", "The NAM model installation was canceled before publication"); + return juce::var(result.get()); + } + + // The download and checksum verification use a unique temporary file and + // need no shared lock. Serialize only the final target publication and the + // manifest read/modify/write transaction across windows and processes. + const auto verifiedFileSize = downloadedFile.getSize(); + std::unique_ptr manifestLock; + NAMLibraryTransactionRecoveryResult prePublicationRecovery; + const auto namRoot = getOpenStudioNAMRoot(); + const auto manifestFile = getOpenStudioNAMManifestJson(); + juce::var manifest; + if (! previewMode) + { + manifestLock = std::make_unique(); + if (! manifestLock->locked()) + return makeNAMLibraryLockFailure(getOpenStudioNAMManifestJson()); + + prePublicationRecovery = + recoverPendingNAMLibraryTransactionLocked( + namRoot, manifestFile); + if (! prePublicationRecovery.success) + { + result->setProperty("error", prePublicationRecovery.error); + applyNAMLibraryRecoveryStatus( + *result, prePublicationRecovery); + return juce::var(result.get()); + } + + const auto readResult = readNAMLibraryManifestStrictLocked( + manifestFile); + if (! readResult.success) + { + result->setProperty("error", readResult.error); + return juce::var(result.get()); + } + manifest = readResult.manifest; + applyNAMLibraryRecoveryStatus( + *result, prePublicationRecovery); + } + else if (! temporaryDownload.overwriteTargetFileWithTemporary()) + { + result->setProperty("error", "Could not publish the verified NAM download into the library"); + return juce::var(result.get()); + } + + auto* manifestObject = previewMode ? nullptr : manifest.getDynamicObject(); + juce::Array installed; + bool preservedFavorite = false; + bool replacedExistingRecord = false; + juce::String preservedInstalledAt; + if (manifestObject != nullptr) + { + const auto installedVar = manifestObject->getProperty("installed"); + if (auto* existingArray = installedVar.getArray()) + installed = *existingArray; + } + + const auto nowIso = juce::Time::getCurrentTime().toISO8601(true); + juce::DynamicObject::Ptr record = new juce::DynamicObject(); + record->setProperty("modelId", modelId); + record->setProperty("toneId", toneId); + record->setProperty("name", displayName); + record->setProperty("architecture", architecture); + record->setProperty("modelUrl", modelUrl); + record->setProperty("sourceUrl", sourceUrl); + record->setProperty("license", license); + record->setProperty("creator", creator); + record->setProperty("gearType", gearType); + record->setProperty("toneTitle", toneTitle); + record->setProperty("checksum", normaliseNAMSha256(checksum)); + record->setProperty("fileSha256", actualSha256); + record->setProperty("assetId", "sha256:" + actualSha256); + record->setProperty("checksumVerified", checksum.trim().isNotEmpty()); + record->setProperty("localPath", targetFile.getFullPathName()); + record->setProperty("libraryFileName", libraryFileName); + record->setProperty("source", "tone3000"); + record->setProperty("sourceProvider", "tone3000"); + record->setProperty("preview", previewMode); + record->setProperty("missing", false); + record->setProperty("missingSince", juce::var()); + record->setProperty("fileSizeBytes", static_cast(verifiedFileSize)); + record->setProperty("lastSeenMetadata", modelPayload); + record->setProperty("lastSeenAt", nowIso); + record->setProperty("catalogSeenAt", nowIso); + record->setProperty("manifestUpdatedAt", nowIso); + record->setProperty("installedAt", nowIso); + record->setProperty("updatedAt", nowIso); + record->setProperty("reinstalled", false); + record->setProperty("favorite", false); + + if (! previewMode && manifestObject != nullptr) + { + for (int i = installed.size() - 1; i >= 0; --i) + { + if (auto* existing = installed.getReference(i).getDynamicObject()) + { + const bool sameModel = static_cast(existing->getProperty("modelId")) == modelId && modelId > 0; + const bool samePath = areNAMPathsEquivalent( + existing->getProperty("localPath").toString(), + targetFile.getFullPathName()); + if (sameModel || samePath) + { + replacedExistingRecord = true; + preservedFavorite = preservedFavorite || static_cast(existing->getProperty("favorite")); + if (preservedInstalledAt.isEmpty()) + preservedInstalledAt = existing->getProperty("installedAt").toString(); + installed.remove(i); + } + } + } + record->setProperty("reinstalled", replacedExistingRecord); + record->setProperty("favorite", preservedFavorite); + if (preservedInstalledAt.isNotEmpty()) + record->setProperty("installedAt", preservedInstalledAt); + installed.add(juce::var(record.get())); + manifestObject->setProperty("installed", installed); + } + + if (! previewMode) + { + const auto publication = + publishNAMLibraryAssetAndManifestLocked( + downloadedFile, + targetFile, + namRoot, + manifestFile, + manifest, + actualSha256); + applyNAMLibraryPublicationStatus(*result, publication); + if (! publication.success) + { + result->setProperty("error", publication.error); + return juce::var(result.get()); + } + if (publication.recoveryPending) + { + result->setProperty( + "warning", + "The NAM model was installed, but transaction cleanup will be retried during the next library refresh. " + + publication.error); + } + } + result->setProperty("success", true); + result->setProperty("record", juce::var(record.get())); + return juce::var(result.get()); +} + +juce::var commitNAMPreviewToneToLibrary(juce::var recordPayload, juce::var metadataPayload, juce::var rackStatePayload) +{ + if (recordPayload.isString()) + recordPayload = juce::JSON::parse(recordPayload.toString()); + if (metadataPayload.isString()) + metadataPayload = juce::JSON::parse(metadataPayload.toString()); + if (rackStatePayload.isString()) + rackStatePayload = juce::JSON::parse(rackStatePayload.toString()); + + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + + auto* record = recordPayload.getDynamicObject(); + if (record == nullptr) + { + result->setProperty("error", "Invalid NAM preview record"); + return juce::var(result.get()); + } + + const auto localPath = record->getProperty("localPath").toString(); + if (localPath.isEmpty()) + { + result->setProperty("error", "Preview record has no local file"); + return juce::var(result.get()); + } + + juce::File sourceFile(localPath); + if (! sourceFile.existsAsFile()) + { + result->setProperty("error", "Preview file is missing"); + return juce::var(result.get()); + } + + auto expectedPreviewSha256 = record->getProperty("fileSha256").toString(); + if (expectedPreviewSha256.isEmpty()) + expectedPreviewSha256 = record->getProperty("checksum").toString(); + + juce::String actualPreviewSha256; + juce::String checksumError; + if (! verifyNAMFileSha256(sourceFile, expectedPreviewSha256, actualPreviewSha256, checksumError)) + { + result->setProperty("error", checksumError + + " The preview was not promoted; its source file was retained so the rack can still recover or revert safely."); + return juce::var(result.get()); + } + + const int modelId = static_cast(record->getProperty("modelId")); + const int toneId = static_cast(record->getProperty("toneId")); + auto finalFile = sourceFile; + std::unique_ptr promotedFile; + if (isFileInsideNAMPreviews(sourceFile)) + { + const auto targetDir = getOpenStudioNAMRoot() + .getChildFile("library") + .getChildFile("tone-" + juce::String(toneId > 0 ? toneId : 0)); + const auto directoryResult = targetDir.createDirectory(); + if (directoryResult.failed()) + { + result->setProperty("error", "Could not create the NAM library directory: " + directoryResult.getErrorMessage()); + return juce::var(result.get()); + } + + auto libraryFileName = record->getProperty("libraryFileName").toString().trim(); + if (libraryFileName.isEmpty()) + libraryFileName = sourceFile.getFileName(); + libraryFileName = juce::File::createLegalFileName(libraryFileName); + if (libraryFileName.isEmpty()) + { + result->setProperty("error", "Could not derive a safe NAM library file name"); + return juce::var(result.get()); + } + + finalFile = targetDir.getChildFile(libraryFileName); + promotedFile = std::make_unique( + finalFile, juce::TemporaryFile::useHiddenFile); + if (! sourceFile.copyFileTo(promotedFile->getFile())) + { + result->setProperty( + "error", + "Could not prepare the verified preview for the NAM library"); + return juce::var(result.get()); + } + + juce::String copiedSha256; + juce::String copiedChecksumError; + if (! verifyNAMFileSha256( + promotedFile->getFile(), + actualPreviewSha256, + copiedSha256, + copiedChecksumError)) + { + result->setProperty( + "error", + copiedChecksumError + + " The preview was not promoted; its source file was retained."); + return juce::var(result.get()); + } + } + + const ScopedNAMLibraryMutationLock manifestLock; + if (! manifestLock.locked()) + return makeNAMLibraryLockFailure(getOpenStudioNAMManifestJson()); + + const auto namRoot = getOpenStudioNAMRoot(); + const auto manifestFile = getOpenStudioNAMManifestJson(); + const auto prePublicationRecovery = + recoverPendingNAMLibraryTransactionLocked( + namRoot, manifestFile); + if (! prePublicationRecovery.success) + { + result->setProperty("error", prePublicationRecovery.error); + applyNAMLibraryRecoveryStatus( + *result, prePublicationRecovery); + return juce::var(result.get()); + } + + const auto readResult = readNAMLibraryManifestStrictLocked( + manifestFile); + if (! readResult.success) + { + result->setProperty("error", readResult.error); + return juce::var(result.get()); + } + auto manifest = readResult.manifest; + applyNAMLibraryRecoveryStatus( + *result, prePublicationRecovery); + auto* manifestObject = manifest.getDynamicObject(); + const auto installedVar = manifestObject != nullptr ? manifestObject->getProperty("installed") : juce::var(); + auto* installedArray = installedVar.getArray(); + if (manifestObject == nullptr || installedArray == nullptr) + { + result->setProperty("error", "NAM library manifest is invalid"); + return juce::var(result.get()); + } + + juce::Array installed = *installedArray; + bool preservedFavorite = static_cast(record->getProperty("favorite")); + juce::String preservedInstalledAt = record->getProperty("installedAt").toString(); + const auto nowIso = juce::Time::getCurrentTime().toISO8601(true); + + for (int i = installed.size() - 1; i >= 0; --i) + { + if (auto* existing = installed.getReference(i).getDynamicObject()) + { + const bool sameModel = modelId > 0 && static_cast(existing->getProperty("modelId")) == modelId; + const bool samePath = areNAMPathsEquivalent( + existing->getProperty("localPath").toString(), + finalFile.getFullPathName()); + if (sameModel || samePath) + { + preservedFavorite = preservedFavorite || static_cast(existing->getProperty("favorite")); + if (preservedInstalledAt.isEmpty()) + preservedInstalledAt = existing->getProperty("installedAt").toString(); + installed.remove(i); + } + } + } + + if (auto* metadata = metadataPayload.getDynamicObject()) + preservedFavorite = preservedFavorite || static_cast(metadata->getProperty("favorite")); + + record->setProperty("localPath", finalFile.getFullPathName()); + record->setProperty("preview", false); + record->setProperty("missing", false); + record->setProperty("missingSince", juce::var()); + const auto publishedFileSize = promotedFile != nullptr + ? promotedFile->getFile().getSize() + : finalFile.getSize(); + record->setProperty( + "fileSizeBytes", static_cast(publishedFileSize)); + record->setProperty("fileSha256", actualPreviewSha256); + record->setProperty("assetId", "sha256:" + actualPreviewSha256); + record->setProperty("installedAt", preservedInstalledAt.isNotEmpty() ? preservedInstalledAt : nowIso); + record->setProperty("updatedAt", nowIso); + record->setProperty("manifestUpdatedAt", nowIso); + record->setProperty("favorite", preservedFavorite); + record->setProperty("saveMetadata", metadataPayload); + record->setProperty("rackState", rackStatePayload); + + installed.add(recordPayload); + manifestObject->setProperty("installed", installed); + if (promotedFile != nullptr) + { + const auto publication = + publishNAMLibraryAssetAndManifestLocked( + promotedFile->getFile(), + finalFile, + namRoot, + manifestFile, + manifest, + actualPreviewSha256); + applyNAMLibraryPublicationStatus(*result, publication); + if (! publication.success) + { + result->setProperty("error", publication.error); + return juce::var(result.get()); + } + if (publication.recoveryPending) + { + result->setProperty( + "warning", + "The preview was saved, but transaction cleanup will be retried during the next library refresh. " + + publication.error); + } + } + else if (! persistNAMLibraryManifestLocked( + manifestFile, manifest)) + { + result->setProperty( + "error", + "Could not persist the NAM library manifest after preview promotion"); + return juce::var(result.get()); + } + + result->setProperty("success", true); + result->setProperty("record", recordPayload); + return juce::var(result.get()); +} + +juce::var discardNAMPreviewFile(juce::var recordPayload) +{ + if (recordPayload.isString()) + recordPayload = juce::JSON::parse(recordPayload.toString()); + + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + + auto* record = recordPayload.getDynamicObject(); + const auto localPath = record != nullptr ? record->getProperty("localPath").toString() : juce::String(); + if (localPath.isEmpty()) + { + result->setProperty("error", "Invalid NAM preview record"); + return juce::var(result.get()); + } + + juce::File file(localPath); + if (! isFileInsideNAMPreviews(file)) + { + result->setProperty("success", true); + result->setProperty("deleteSkipped", true); + result->setProperty("retained", file.existsAsFile()); + result->setProperty("recoverable", file.existsAsFile()); + return juce::var(result.get()); + } + + if (! file.existsAsFile()) + { + result->setProperty("success", true); + result->setProperty("deletedFile", false); + result->setProperty("alreadyMissing", true); + return juce::var(result.get()); + } + + return makeNAMPreviewRetentionResult( + "The NAM preview was retained because host-wide rack references cannot yet be proven clear"); +} + +juce::var cleanupNAMPreviewFiles(double maxAgeHours) +{ + juce::ignoreUnused(maxAgeHours); + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + result->setProperty("cleaned", 0); + result->setProperty("deleteSkipped", true); + result->setProperty("error", + "Bulk NAM preview cleanup is disabled because the host cannot yet prove that every rack has released each file"); + return juce::var(result.get()); +} + +juce::var runNAMLibraryReliabilityRegressionImpl() +{ + juce::Array checks; + bool overallPass = true; + const auto addCheck = [&] (const juce::String& id, + bool pass, + const juce::String& detail) + { + juce::DynamicObject::Ptr check = new juce::DynamicObject(); + check->setProperty("id", id); + check->setProperty("pass", pass); + check->setProperty("detail", detail); + checks.add(juce::var(check.get())); + overallPass = overallPass && pass; + }; + const auto makeRecord = [] (const juce::File& file, + const juce::String& sha256, + int modelId, + bool favorite) + { + juce::DynamicObject::Ptr record = new juce::DynamicObject(); + record->setProperty("modelId", modelId); + record->setProperty("toneId", 1); + record->setProperty("localPath", file.getFullPathName()); + record->setProperty("fileSha256", sha256); + record->setProperty("fileSizeBytes", + static_cast(file.getSize())); + record->setProperty("favorite", favorite); + return juce::var(record.get()); + }; + const auto makeManifest = [] (const juce::Array& installed) + { + juce::DynamicObject::Ptr manifest = new juce::DynamicObject(); + manifest->setProperty("schemaVersion", 1); + manifest->setProperty("installed", juce::var(installed)); + return juce::var(manifest.get()); + }; + const auto makeFixtureRoot = [] (const juce::String& label) + { + const auto root = juce::File::getSpecialLocation( + juce::File::tempDirectory).getChildFile( + "OpenStudio_NAM_Library_Reliability_" + label + "_" + + juce::Uuid().toString()); + (void) root.getChildFile("library") + .getChildFile("tone-1").createDirectory(); + return root; + }; + const auto countRollbackFiles = [] (const juce::File& root) + { + juce::Array files; + root.getChildFile("library").findChildFiles( + files, + juce::File::findFiles, + true, + "*" + juce::String(kNAMLibraryRollbackTag) + "*"); + return files.size(); + }; + + { + const auto root = makeFixtureRoot("new-install-failure"); + const auto manifestFile = root.getChildFile( + "library_manifest.json"); + const auto target = root.getChildFile("library") + .getChildFile("tone-1").getChildFile("new-model.nam"); + const auto initialManifest = makeEmptyNAMLibraryManifest(); + const bool fixtureReady = persistJsonFileAtomically( + manifestFile, initialManifest); + juce::TemporaryFile staged( + target, juce::TemporaryFile::useHiddenFile); + const bool stagedReady = staged.getFile().replaceWithText( + "new-install-bytes"); + juce::String newSha256; + const bool hashReady = stagedReady + && calculateUncancelledFileSha256( + staged.getFile(), newSha256); + juce::Array installed; + installed.add(makeRecord(target, newSha256, 101, false)); + const auto desiredManifest = makeManifest(installed); + NAMLibraryPublicationResult publication; + if (fixtureReady && hashReady) + { + const ScopedNAMLibraryMutationLock lock; + const NAMLibraryPublicationTestHooks hooks { + NAMLibraryPublicationFailurePoint::manifestPublish + }; + if (lock.locked()) + { + publication = publishNAMLibraryAssetAndManifestLocked( + staged.getFile(), + target, + root, + manifestFile, + desiredManifest, + newSha256, + &hooks); + } + } + const auto diskManifest = readNAMLibraryManifestStrictLocked( + manifestFile); + const auto diskInstalledValue = diskManifest.success + ? diskManifest.manifest.getProperty("installed", {}) + : juce::var(); + const auto* diskInstalled = diskInstalledValue.getArray(); + const bool targetRemoved = ! target.existsAsFile(); + const bool noMarker = ! root.getChildFile( + kNAMLibraryTransactionMarkerName).existsAsFile(); + const bool noRollback = countRollbackFiles(root) == 0; + const bool cleaned = cleanupNAMLibraryRegressionDirectory(root); + const bool pass = fixtureReady + && hashReady + && ! publication.success + && publication.recoveryAttempted + && publication.recoverySucceeded + && ! publication.recoveryPending + && targetRemoved + && diskInstalled != nullptr + && diskInstalled->isEmpty() + && noMarker + && noRollback + && cleaned; + addCheck( + "nam_library_new_install_manifest_failure_removes_uncommitted_asset", + pass, + "An injected manifest publication failure must remove a newly published target, preserve the prior empty manifest, verify recovery, and leave no rollback artifact."); + } + + { + const auto root = makeFixtureRoot("overwrite-rollback"); + const auto manifestFile = root.getChildFile( + "library_manifest.json"); + const auto target = root.getChildFile("library") + .getChildFile("tone-1").getChildFile("model.nam"); + const bool oldReady = target.replaceWithText("old-model-bytes"); + juce::String oldSha256; + const bool oldHashReady = oldReady + && calculateUncancelledFileSha256(target, oldSha256); + juce::Array oldInstalled; + oldInstalled.add(makeRecord(target, oldSha256, 102, true)); + const bool manifestReady = persistJsonFileAtomically( + manifestFile, makeManifest(oldInstalled)); + juce::TemporaryFile staged( + target, juce::TemporaryFile::useHiddenFile); + const bool stagedReady = staged.getFile().replaceWithText( + "replacement-model-bytes"); + juce::String newSha256; + const bool newHashReady = stagedReady + && calculateUncancelledFileSha256( + staged.getFile(), newSha256); + juce::Array newInstalled; + newInstalled.add(makeRecord(target, newSha256, 102, true)); + NAMLibraryPublicationResult publication; + if (oldHashReady && manifestReady && newHashReady) + { + const ScopedNAMLibraryMutationLock lock; + const NAMLibraryPublicationTestHooks hooks { + NAMLibraryPublicationFailurePoint::manifestPublish + }; + if (lock.locked()) + { + publication = publishNAMLibraryAssetAndManifestLocked( + staged.getFile(), + target, + root, + manifestFile, + makeManifest(newInstalled), + newSha256, + &hooks); + } + } + juce::String restoredSha256; + const bool restoredHashReady = calculateUncancelledFileSha256( + target, restoredSha256); + const auto diskManifest = readNAMLibraryManifestStrictLocked( + manifestFile); + const auto diskInstalledValue = diskManifest.success + ? diskManifest.manifest.getProperty("installed", {}) + : juce::var(); + const auto* diskInstalled = diskInstalledValue.getArray(); + const bool manifestPreserved = diskInstalled != nullptr + && diskInstalled->size() == 1 + && normaliseNAMSha256( + diskInstalled->getReference(0).getProperty( + "fileSha256", {}).toString()) == oldSha256; + const bool noMarker = ! root.getChildFile( + kNAMLibraryTransactionMarkerName).existsAsFile(); + const bool noRollback = countRollbackFiles(root) == 0; + const bool cleaned = cleanupNAMLibraryRegressionDirectory(root); + addCheck( + "nam_library_overwrite_manifest_failure_restores_prior_bytes", + oldHashReady && manifestReady && newHashReady + && ! publication.success + && publication.recoverySucceeded + && restoredHashReady + && restoredSha256 == oldSha256 + && manifestPreserved + && noMarker + && noRollback + && cleaned, + "An injected overwrite failure must restore the exact prior target bytes and manifest record before reporting failure."); + } + + { + const auto root = makeFixtureRoot("crash-recovery"); + const auto manifestFile = root.getChildFile( + "library_manifest.json"); + const auto target = root.getChildFile("library") + .getChildFile("tone-1").getChildFile("model.nam"); + const bool oldReady = target.replaceWithText("crash-old-bytes"); + juce::String oldSha256; + const bool oldHashReady = oldReady + && calculateUncancelledFileSha256(target, oldSha256); + juce::Array oldInstalled; + oldInstalled.add(makeRecord(target, oldSha256, 103, false)); + const bool manifestReady = persistJsonFileAtomically( + manifestFile, makeManifest(oldInstalled)); + juce::TemporaryFile staged( + target, juce::TemporaryFile::useHiddenFile); + const bool stagedReady = staged.getFile().replaceWithText( + "crash-new-bytes"); + juce::String newSha256; + const bool newHashReady = stagedReady + && calculateUncancelledFileSha256( + staged.getFile(), newSha256); + juce::Array newInstalled; + newInstalled.add(makeRecord(target, newSha256, 103, false)); + NAMLibraryPublicationResult interrupted; + NAMLibraryTransactionRecoveryResult recovery; + if (oldHashReady && manifestReady && newHashReady) + { + const ScopedNAMLibraryMutationLock lock; + const NAMLibraryPublicationTestHooks hooks { + NAMLibraryPublicationFailurePoint::afterAssetPublish + }; + if (lock.locked()) + { + interrupted = publishNAMLibraryAssetAndManifestLocked( + staged.getFile(), + target, + root, + manifestFile, + makeManifest(newInstalled), + newSha256, + &hooks); + recovery = recoverPendingNAMLibraryTransactionLocked( + root, manifestFile); + } + } + juce::String recoveredSha256; + const bool recoveredHashReady = + calculateUncancelledFileSha256(target, recoveredSha256); + const bool artifactsCleared = ! root.getChildFile( + kNAMLibraryTransactionMarkerName).existsAsFile() + && countRollbackFiles(root) == 0; + const bool cleaned = cleanupNAMLibraryRegressionDirectory(root); + addCheck( + "nam_library_crash_marker_rolls_back_pre_manifest_publication", + oldHashReady && manifestReady && newHashReady + && ! interrupted.success + && interrupted.recoveryPending + && recovery.success + && recovery.markerFound + && recovery.rolledBack + && recoveredHashReady + && recoveredSha256 == oldSha256 + && artifactsCleared + && cleaned, + "A retained intent marker after asset publication must restore the prior bytes when the expected manifest digest was never committed."); + } + + { + const auto root = makeFixtureRoot("committed-crash-recovery"); + const auto manifestFile = root.getChildFile( + "library_manifest.json"); + const auto target = root.getChildFile("library") + .getChildFile("tone-1").getChildFile("model.nam"); + const bool oldReady = target.replaceWithText("committed-old"); + juce::String oldSha256; + const bool oldHashReady = oldReady + && calculateUncancelledFileSha256(target, oldSha256); + juce::Array oldInstalled; + oldInstalled.add(makeRecord(target, oldSha256, 104, false)); + const bool manifestReady = persistJsonFileAtomically( + manifestFile, makeManifest(oldInstalled)); + juce::TemporaryFile staged( + target, juce::TemporaryFile::useHiddenFile); + const bool stagedReady = staged.getFile().replaceWithText( + "committed-new"); + juce::String newSha256; + const bool newHashReady = stagedReady + && calculateUncancelledFileSha256( + staged.getFile(), newSha256); + juce::Array newInstalled; + newInstalled.add(makeRecord(target, newSha256, 104, false)); + NAMLibraryPublicationResult interrupted; + NAMLibraryTransactionRecoveryResult recovery; + if (oldHashReady && manifestReady && newHashReady) + { + const ScopedNAMLibraryMutationLock lock; + const NAMLibraryPublicationTestHooks hooks { + NAMLibraryPublicationFailurePoint::afterManifestPublish + }; + if (lock.locked()) + { + interrupted = publishNAMLibraryAssetAndManifestLocked( + staged.getFile(), + target, + root, + manifestFile, + makeManifest(newInstalled), + newSha256, + &hooks); + recovery = recoverPendingNAMLibraryTransactionLocked( + root, manifestFile); + } + } + juce::String recoveredSha256; + const bool recoveredHashReady = + calculateUncancelledFileSha256(target, recoveredSha256); + const bool artifactsCleared = ! root.getChildFile( + kNAMLibraryTransactionMarkerName).existsAsFile() + && countRollbackFiles(root) == 0; + const bool cleaned = cleanupNAMLibraryRegressionDirectory(root); + addCheck( + "nam_library_crash_marker_finalizes_committed_manifest", + oldHashReady && manifestReady && newHashReady + && ! interrupted.success + && interrupted.recoveryPending + && recovery.success + && recovery.markerFound + && recovery.committed + && recoveredHashReady + && recoveredSha256 == newSha256 + && artifactsCleared + && cleaned, + "A retained marker after manifest publication must keep the committed new bytes and discard only the rollback artifact."); + } + + { + const auto root = makeFixtureRoot("concurrent-hash"); + const auto manifestFile = root.getChildFile( + "library_manifest.json"); + const auto catalogFile = root.getChildFile("catalog.json"); + const auto toneDirectory = root.getChildFile("library") + .getChildFile("tone-1"); + const auto stableFile = toneDirectory.getChildFile("stable.nam"); + const auto staleFile = toneDirectory.getChildFile("stale.nam"); + const auto replacementFile = toneDirectory.getChildFile( + "replacement.nam"); + const bool assetsReady = stableFile.replaceWithText( + "stable-hash-source") + && staleFile.replaceWithText("stale-hash-source") + && replacementFile.replaceWithText( + "replacement-with-different-size"); + juce::Array installed; + installed.add(makeRecord(stableFile, {}, 201, false)); + installed.add(makeRecord(staleFile, {}, 202, false)); + const bool manifestReady = persistJsonFileAtomically( + manifestFile, makeManifest(installed)); + std::atomic hashEntered { false }; + std::atomic releaseHash { false }; + juce::var refreshResult; + std::thread refreshThread([&] + { + refreshResult = refreshNAMLibraryManifestAtPaths( + root, + manifestFile, + catalogFile, + true, + [&] (const juce::File& file, + juce::String& sha256, + juce::String& error) + { + if (areNAMPathsEquivalent( + file.getFullPathName(), + stableFile.getFullPathName())) + { + hashEntered.store(true, std::memory_order_release); + while (! releaseHash.load( + std::memory_order_acquire)) + { + juce::Thread::sleep(1); + } + } + if (! calculateUncancelledFileSha256(file, sha256)) + { + error = "fixture hash failed"; + return false; + } + return true; + }); + }); + for (int attempt = 0; + attempt < 2000 + && ! hashEntered.load(std::memory_order_acquire); + ++attempt) + { + juce::Thread::sleep(1); + } + bool concurrentUpdatePersisted = false; + if (hashEntered.load(std::memory_order_acquire)) + { + const ScopedNAMLibraryMutationLock lock; + if (lock.locked()) + { + const auto latest = readNAMLibraryManifestStrictLocked( + manifestFile); + auto latestManifest = latest.manifest; + auto latestInstalledValue = latest.success + ? latestManifest.getProperty("installed", {}) + : juce::var(); + auto* latestInstalled = latestInstalledValue.getArray(); + if (latestInstalled != nullptr + && latestInstalled->size() == 2) + { + if (auto* stable = latestInstalled->getReference(0) + .getDynamicObject()) + { + stable->setProperty("favorite", true); + } + if (auto* stale = latestInstalled->getReference(1) + .getDynamicObject()) + { + stale->setProperty( + "localPath", + replacementFile.getFullPathName()); + stale->setProperty( + "fileSizeBytes", + static_cast( + replacementFile.getSize())); + } + latestInstalled->add(makeRecord( + replacementFile, {}, 203, true)); + concurrentUpdatePersisted = + persistNAMLibraryManifestLocked( + manifestFile, latestManifest); + } + } + } + releaseHash.store(true, std::memory_order_release); + refreshThread.join(); + + const auto finalRead = readNAMLibraryManifestStrictLocked( + manifestFile); + const auto finalInstalledValue = finalRead.success + ? finalRead.manifest.getProperty("installed", {}) + : juce::var(); + auto* finalInstalled = finalInstalledValue.getArray(); + bool stableFavoritePreserved = false; + bool stableHashMerged = false; + bool staleReplacementPreserved = false; + bool staleHashRejected = false; + bool addedRecordPreserved = false; + if (finalInstalled != nullptr) + { + for (const auto& value : *finalInstalled) + { + auto* record = value.getDynamicObject(); + if (record == nullptr) + continue; + const int modelId = static_cast( + record->getProperty("modelId")); + if (modelId == 201) + { + stableFavoritePreserved = static_cast( + record->getProperty("favorite")); + const auto sha = normaliseNAMSha256( + record->getProperty("fileSha256").toString()); + stableHashMerged = sha.length() == 64; + } + else if (modelId == 202) + { + staleReplacementPreserved = areNAMPathsEquivalent( + record->getProperty("localPath").toString(), + replacementFile.getFullPathName()); + staleHashRejected = normaliseNAMSha256( + record->getProperty("fileSha256").toString()) + .isEmpty(); + } + else if (modelId == 203) + { + addedRecordPreserved = true; + } + } + } + const auto refreshSuccessValue = refreshResult.getProperty( + "success", true); + const bool refreshSucceeded = refreshResult.isObject() + && (! refreshSuccessValue.isBool() + || static_cast(refreshSuccessValue)); + const bool cleaned = cleanupNAMLibraryRegressionDirectory(root); + addCheck( + "nam_library_refresh_hash_merge_preserves_concurrent_manifest_update", + assetsReady && manifestReady + && hashEntered.load(std::memory_order_acquire) + && concurrentUpdatePersisted + && refreshSucceeded + && finalInstalled != nullptr + && finalInstalled->size() == 3 + && stableFavoritePreserved + && stableHashMerged + && staleReplacementPreserved + && staleHashRejected + && addedRecordPreserved + && cleaned, + "Hashing must run outside the OS lock; the final merge must preserve a concurrent favorite, added record, and replacement path while rejecting the stale hash observation."); + } + + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", overallPass); + result->setProperty("checks", juce::var(checks)); + return juce::var(result.get()); +} + +bool isLocalFrontendDevServerReachable() +{ + if (juce::SystemStats::getEnvironmentVariable ("OPENSTUDIO_FORCE_PACKAGED_FRONTEND", {}).trim() == "1") + { + juce::Logger::writeToLog("OPENSTUDIO_FORCE_PACKAGED_FRONTEND=1; loading the packaged frontend."); + return false; + } + + int statusCode = 0; + auto input = juce::URL("http://127.0.0.1:5183/").createInputStream( + juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs(750) + .withNumRedirectsToFollow(1) + .withStatusCode(&statusCode)); + + if (input == nullptr) + return false; + + if (statusCode >= 400) + { + juce::Logger::writeToLog("localhost:5183 responded with HTTP " + juce::String(statusCode) + + "; falling back to the packaged frontend."); + return false; + } + + const auto indexHtml = input->readEntireStreamAsString(); + const bool looksLikeOpenStudioVite = + indexHtml.contains("OpenStudio") + && indexHtml.contains("id=\"root\"") + && (indexHtml.contains("/src/main.tsx") || indexHtml.contains("./src/main.tsx")); + + if (! looksLikeOpenStudioVite) + { + juce::Logger::writeToLog("localhost:5183 is reachable, but it did not return the OpenStudio Vite index; " + "falling back to the packaged frontend."); + return false; + } + + return true; +} + +juce::String appendFrontendStartupQuery(const juce::String& baseUrl, + MainComponent::WindowRole role, + MainComponent::StartupMode startupMode, + const juce::String& windowInstanceId = {}) +{ + juce::String url = baseUrl; + const auto appendParameter = [&url](const juce::String& key, const juce::String& value) + { + const auto separator = url.containsChar('?') ? "&" : "?"; + url << separator << key << "=" << juce::URL::addEscapeChars(value, true); + }; + + appendParameter("window", getWindowRoleQueryValue(role)); + appendParameter("startup", getStartupModeQueryValue(startupMode)); + appendParameter("platform", getHostPlatformQueryValue()); + appendParameter("windowChrome", getWindowChromeQueryValue(role)); + if (windowInstanceId.isNotEmpty()) + appendParameter("sessionId", windowInstanceId); + appendParameter("cacheBust", juce::String(juce::Time::getCurrentTime().toMilliseconds())); + return url; +} + +juce::String describeFrontendStartupState(MainComponent::FrontendStartupState state) +{ + switch (state) + { + case MainComponent::FrontendStartupState::idle: return "idle"; + case MainComponent::FrontendStartupState::navigationStarted: return "navigation-started"; + case MainComponent::FrontendStartupState::bootStarted: return "boot-started"; + case MainComponent::FrontendStartupState::ready: return "ready"; + case MainComponent::FrontendStartupState::failed: return "failed"; + case MainComponent::FrontendStartupState::timedOut: return "timed-out"; + } + + return "unknown"; +} + +juce::String determineStartupFailureCategory(const StartupDependencyStatus& dependencyStatus) +{ + if (! dependencyStatus.shellRuntimeAssetsPresent) + return dependencyStatus.packagedFrontendPresent ? "shell-assets-missing" : "packaged-frontend-missing"; + +#if JUCE_WINDOWS + if (! dependencyStatus.browserBackendSupported) + { + if (! dependencyStatus.vcRedistInstalled) + return "vc-redist-missing"; + + if (dependencyStatus.webView2RuntimeVersion.isEmpty()) + return "webview2-runtime-missing"; + + return "webview2-backend-unusable"; + } +#else + if (! dependencyStatus.browserBackendSupported) + return "macos-backend-unavailable"; +#endif + + return "ready"; +} + +juce::String buildStartupFailureSummary(const StartupDependencyStatus& dependencyStatus) +{ + const auto failureCategory = determineStartupFailureCategory(dependencyStatus); + + if (failureCategory == "packaged-frontend-missing") + return "Packaged frontend is missing."; + + if (failureCategory == "shell-assets-missing") + return "Required shell assets are missing."; + +#if JUCE_WINDOWS + if (failureCategory == "vc-redist-missing") + return "Microsoft Visual C++ Redistributable is missing."; + + if (failureCategory == "webview2-runtime-missing") + return "Microsoft Edge WebView2 Runtime is missing."; + + if (failureCategory == "webview2-backend-unusable") + return "WebView2 Runtime was detected, but JUCE still reports the backend as unavailable."; +#else + if (failureCategory == "macos-backend-unavailable") + return "The system browser backend is unavailable on this macOS installation."; +#endif + + return "OpenStudio shell startup self-test passed."; +} + +juce::String buildStartupSelfTestText(const StartupDependencyStatus& dependencyStatus) +{ + juce::StringArray lines; + lines.add("OpenStudio Startup Self-Test"); + lines.add("shellReady=" + juce::String(determineStartupFailureCategory(dependencyStatus) == "ready" ? "true" : "false")); + lines.add("failureCategory=" + determineStartupFailureCategory(dependencyStatus)); + lines.add("summary=" + buildStartupFailureSummary(dependencyStatus)); + lines.add("browserBackend=" + dependencyStatus.browserBackend); + lines.add("browserBackendSupported=" + juce::String(dependencyStatus.browserBackendSupported ? "true" : "false")); + lines.add("packagedFrontendPresent=" + juce::String(dependencyStatus.packagedFrontendPresent ? "true" : "false")); + lines.add("packagedFrontendPath=" + dependencyStatus.packagedFrontendPath); + lines.add("shellRuntimeAssetsPresent=" + juce::String(dependencyStatus.shellRuntimeAssetsPresent ? "true" : "false")); + lines.add("missingShellRuntimeAssets=" + dependencyStatus.missingShellRuntimeAssets.joinIntoString(" | ")); + lines.add("featureRuntimeAssetsPresent=" + juce::String(dependencyStatus.featureRuntimeAssetsPresent ? "true" : "false")); + lines.add("missingFeatureRuntimeAssets=" + dependencyStatus.missingFeatureRuntimeAssets.joinIntoString(" | ")); + lines.add("startupLogPath=" + getStartupLogFile().getFullPathName()); +#if JUCE_WINDOWS + lines.add("webView2UserDataPath=" + getWebView2UserDataFolder().getFullPathName()); + lines.add("webView2RuntimeVersion=" + dependencyStatus.webView2RuntimeVersion); + lines.add("vcRedistInstalled=" + juce::String(dependencyStatus.vcRedistInstalled ? "true" : "false")); + lines.add("vcRedistVersion=" + dependencyStatus.vcRedistVersion); + lines.add("prerequisiteRepairAvailable=" + juce::String(dependencyStatus.repairAvailable ? "true" : "false")); +#endif + return lines.joinIntoString("\n"); +} + +juce::StringArray getNAMModelMutationSlots(const juce::String& stateJson) +{ + juce::StringArray slots; + const auto parsed = juce::JSON::parse(stateJson); + auto* stateObject = parsed.getDynamicObject(); + if (stateObject == nullptr) + return slots; + + auto* modelObject = stateObject->getProperty("modelState").getDynamicObject(); + if (modelObject == nullptr) + modelObject = stateObject; + + const auto addIfTouched = [modelObject, &slots](const juce::Identifier& pathProperty, + const juce::Identifier& clearProperty, + const juce::String& slot) + { + const bool hasPathMutation = modelObject->hasProperty(pathProperty); + const bool hasClearMutation = modelObject->hasProperty(clearProperty) + && static_cast<bool>(modelObject->getProperty(clearProperty)); + if (hasPathMutation || hasClearMutation) + slots.addIfNotAlreadyThere(slot); + }; + + addIfTouched("pedalModelPath", "clearPedalModel", "pedal"); + addIfTouched("ampModelPath", "clearAmpModel", "amp"); + addIfTouched("cabIRPath", "clearCabIR", "cab"); + if (modelObject->hasProperty("pedalModelSize")) + slots.addIfNotAlreadyThere("pedal"); + if (modelObject->hasProperty("ampModelSize")) + slots.addIfNotAlreadyThere("amp"); + bool legacyModelSizeTouched = false; + if (auto* values = + stateObject->getProperty( + "values").getDynamicObject()) + { + legacyModelSizeTouched = + values->hasProperty("namModelSize"); + } + if (auto* parameters = + stateObject->getProperty( + "parameters").getArray()) + { + for (const auto& parameterValue : + *parameters) + { + if (auto* parameter = + parameterValue.getDynamicObject(); + parameter != nullptr + && parameter->getProperty( + "id").toString() + == "namModelSize") + { + legacyModelSizeTouched = true; + break; + } + } + } + if (legacyModelSizeTouched) + { + slots.addIfNotAlreadyThere("pedal"); + slots.addIfNotAlreadyThere("amp"); + } + return slots; +} +} + +juce::CriticalSection MainComponent::instanceListLock; +juce::Array<MainComponent*> MainComponent::activeInstances; + +int MainComponent::runNAMLibraryManifestWriterRegressionChild( + const juce::File& manifestFile, + const juce::String& writerId, + const juce::File& readyFile, + const juce::File& startFile) +{ + const auto temporaryRoot = juce::File::getSpecialLocation( + juce::File::tempDirectory); + const auto regressionDirectory = manifestFile.getParentDirectory(); + const auto safeWriterId = writerId.trim(); + const bool safePaths = juce::File::isAbsolutePath( + manifestFile.getFullPathName()) + && juce::File::isAbsolutePath(readyFile.getFullPathName()) + && juce::File::isAbsolutePath(startFile.getFullPathName()) + && regressionDirectory.isDirectory() + && regressionDirectory.isAChildOf(temporaryRoot) + && regressionDirectory.getFileName().startsWith( + "OpenStudio_NAM_Library_Multiprocess_") + && readyFile.getParentDirectory() == regressionDirectory + && startFile.getParentDirectory() == regressionDirectory + && manifestFile.getFileName() == "library_manifest.json" + && startFile.getFileName() == "start.flag" + && readyFile.getFileName() + == "ready-" + safeWriterId + ".flag" + && safeWriterId.isNotEmpty() + && safeWriterId.length() <= 32 + && safeWriterId.containsOnly( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"); + if (! safePaths) + return 2; + + if (! readyFile.replaceWithText(safeWriterId)) + return 3; + + const auto startDeadline = juce::Time::getMillisecondCounterHiRes() + + 10000.0; + while (! startFile.existsAsFile() + && juce::Time::getMillisecondCounterHiRes() < startDeadline) + { + juce::Thread::sleep(5); + } + if (! startFile.existsAsFile()) + return 4; + + const ScopedNAMLibraryMutationLock manifestLock(10000); + if (! manifestLock.locked()) + return 5; + + auto manifest = parseJsonFileOrDefault(manifestFile, "installed"); + auto* manifestObject = manifest.getDynamicObject(); + auto installedValue = manifestObject != nullptr + ? manifestObject->getProperty("installed") + : juce::var(); + auto* installedArray = installedValue.getArray(); + if (manifestObject == nullptr || installedArray == nullptr) + return 6; + + // Both child processes cross the start barrier together. Keeping the read + // snapshot open briefly makes a missing OS-wide lock deterministically lose + // one writer, while the production lock serializes the complete RMW. + juce::Thread::sleep(250); + bool alreadyPresent = false; + for (const auto& recordValue : *installedArray) + { + if (recordValue.getProperty("writerId", {}).toString() + == safeWriterId) + { + alreadyPresent = true; + break; + } + } + if (! alreadyPresent) + { + juce::DynamicObject::Ptr record = new juce::DynamicObject(); + record->setProperty("writerId", safeWriterId); + record->setProperty( + "modelId", safeWriterId.hashCode()); + installedArray->add(juce::var(record.get())); + manifestObject->setProperty("installed", installedValue); + } + + return persistNAMLibraryManifestLocked(manifestFile, manifest) ? 0 : 7; +} + +juce::var MainComponent::runNAMCatalogNativeRegression() +{ + return runNAMCatalogNativeRegressionImpl(); +} + +void MainComponent::runTone3000NativeTask( + std::function<juce::var()> task, + juce::WebBrowserComponent::NativeFunctionCompletion completion) +{ + if (! tone3000NativeCompletionsEnabled.load( + std::memory_order_acquire)) + { + return; + } + juce::Component::SafePointer<MainComponent> safeThis(this); + auto cancellation = tone3000TaskCancellation; + auto completionState = std::make_shared< + juce::WebBrowserComponent::NativeFunctionCompletion>( + std::move(completion)); + const auto reportSchedulingFailure = [this, &completionState] + (const juce::String& message) + { + if (tone3000NativeCompletionsEnabled.load( + std::memory_order_acquire) + && completionState != nullptr + && static_cast<bool>(*completionState)) + { + (*completionState)(makeTone3000Error(message)); + } + }; + + try + { + tone3000BridgePool.addJob(std::function<void()>([ + safeThis, + cancellation = std::move(cancellation), + task = std::move(task), + completionState]() mutable + { + if (cancellation == nullptr + || cancellation->load(std::memory_order_acquire)) + { + return; + } + + struct ScopedTaskCancellation + { + explicit ScopedTaskCancellation( + const std::atomic<bool>* cancellationIn) + : previous(activeTone3000TaskCancellation) + { + activeTone3000TaskCancellation = cancellationIn; + } + ~ScopedTaskCancellation() + { + activeTone3000TaskCancellation = previous; + } + const std::atomic<bool>* previous = nullptr; + } scopedCancellation(cancellation.get()); + + juce::var result; + try + { + result = task(); + } + catch (const std::exception& exception) + { + result = makeTone3000Error( + "Native NAM/TONE3000 task failed: " + + juce::String(exception.what())); + } + catch (...) + { + result = makeTone3000Error( + "Native NAM/TONE3000 task failed unexpectedly."); + } + + juce::MessageManager::callAsync([ + safeThis, + result = std::move(result), + completionState]() mutable + { + if (safeThis != nullptr + && safeThis->tone3000NativeCompletionsEnabled.load( + std::memory_order_acquire) + && completionState != nullptr + && static_cast<bool>(*completionState)) + { + (*completionState)(result); + } + }); + })); + } + catch (const std::exception& exception) + { + reportSchedulingFailure( + "Could not schedule the native NAM/TONE3000 task: " + + juce::String(exception.what())); + } + catch (...) + { + reportSchedulingFailure( + "Could not schedule the native NAM/TONE3000 task."); + } +} + +std::string MainComponent::makeNAMModelMutationKey(const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::String& slot) +{ + return (trackId + "\x1f" + chainType + "\x1f" + juce::String(fxIndex) + + "\x1f" + slot.trim().toLowerCase()).toStdString(); +} + +juce::uint64 MainComponent::beginNAMModelMutationRequest(const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::String& slot) +{ + const juce::ScopedLock sl(namModelMutationGenerationLock); + const auto generation = ++nextNAMModelMutationGeneration; + namModelMutationGenerations[makeNAMModelMutationKey(trackId, chainType, fxIndex, slot)] = generation; + return generation; +} + +juce::uint64 MainComponent::beginNAMModelMutationRequests( + const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::StringArray& slots, + std::vector<std::pair<juce::String, juce::uint64>>& requests) +{ + const juce::ScopedLock generationLock(namModelMutationGenerationLock); + requests.clear(); + requests.reserve(static_cast<size_t>(slots.size())); + for (const auto& slot : slots) + { + const auto generation = ++nextNAMModelMutationGeneration; + namModelMutationGenerations[makeNAMModelMutationKey(trackId, chainType, fxIndex, slot)] = generation; + requests.emplace_back(slot, generation); + } + return namRackTopologyGeneration; +} + +void MainComponent::invalidateNAMModelMutationRequests(const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::StringArray& slots) +{ + for (const auto& slot : slots) + beginNAMModelMutationRequest(trackId, chainType, fxIndex, slot); +} + +bool MainComponent::isNAMModelMutationRequestCurrent(const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::String& slot, + juce::uint64 generation) +{ + const juce::ScopedLock sl(namModelMutationGenerationLock); + const auto iterator = namModelMutationGenerations.find(makeNAMModelMutationKey(trackId, chainType, fxIndex, slot)); + return iterator != namModelMutationGenerations.end() && iterator->second == generation; +} + +std::shared_ptr<void> MainComponent::acquireNAMModelMutationPublicationLease( + const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const std::vector<std::pair<juce::String, juce::uint64>>& requests, + juce::uint64 topologyGeneration) +{ + // Expensive model/IR preparation happens before this lease is requested. + // Acquire locks in the same state -> generation order as topology handlers, + // then hold both only through the short validated audible publication. This + // keeps render/freeze and topology mutations atomic without blocking the + // WebView message thread for the duration of NAM graph construction. + namModelMutationStateLock.enter(); + namModelMutationGenerationLock.enter(); + bool current = topologyGeneration == namRackTopologyGeneration; + for (const auto& request : requests) + { + const auto iterator = namModelMutationGenerations.find( + makeNAMModelMutationKey(trackId, chainType, fxIndex, request.first)); + current = current && iterator != namModelMutationGenerations.end() + && iterator->second == request.second; + } + + if (! current) + { + namModelMutationGenerationLock.exit(); + namModelMutationStateLock.exit(); + return {}; + } + + return std::shared_ptr<void>( + &namModelMutationStateLock, + [] (void*) + { + namModelMutationGenerationLock.exit(); + namModelMutationStateLock.exit(); + }); +} + +juce::var MainComponent::discardNAMPreviewIfUnused(juce::var recordPayload, + juce::var rackAddressPayload) +{ + // Lock ordering is always rack state first, file/library lifecycle second. + // Neither lock is observed by the audio callback. + const juce::ScopedLock stateLock(namModelMutationStateLock); + const juce::ScopedLock libraryLock(namLibraryMutationLock); + + if (recordPayload.isString()) + recordPayload = juce::JSON::parse(recordPayload.toString()); + + auto* record = recordPayload.getDynamicObject(); + const auto localPath = record != nullptr ? record->getProperty("localPath").toString() : juce::String(); + if (localPath.isEmpty()) + return discardNAMPreviewFile(recordPayload); + + const juce::File previewFile(localPath); + if (! isFileInsideNAMPreviews(previewFile) || ! previewFile.existsAsFile()) + return discardNAMPreviewFile(recordPayload); + + // UUID previews created by current builds carry their deterministic library + // destination separately. Legacy records did not, and their preview path may + // be shared by more than one rack/window, so ownership cannot be proven. + if (record->getProperty("libraryFileName").toString().trim().isEmpty()) + { + return makeNAMPreviewRetentionResult( + "Legacy NAM preview ownership cannot be proven; the file was retained safely"); + } + + if (rackAddressPayload.isString()) + rackAddressPayload = juce::JSON::parse(rackAddressPayload.toString()); + + auto* address = rackAddressPayload.getDynamicObject(); + if (address != nullptr) + { + if (auto* nestedAddress = address->getProperty("address").getDynamicObject()) + address = nestedAddress; + } + + if (address == nullptr) + { + return makeNAMPreviewRetentionResult( + "NAM preview discard needs the target rack address so the model can be proven unloaded"); + } + + const auto trackId = address->getProperty("trackId").toString(); + auto chainType = address->getProperty("chain").toString().trim().toLowerCase(); + if (chainType.isEmpty()) + chainType = address->getProperty("chainType").toString().trim().toLowerCase(); + const int fxIndex = address->hasProperty("fxIndex") + ? static_cast<int>(address->getProperty("fxIndex")) + : -1; + const bool supportedChain = chainType == "input" || chainType == "track" || chainType == "master"; + if (! supportedChain || fxIndex < 0 || (chainType != "master" && trackId.isEmpty())) + { + return makeNAMPreviewRetentionResult( + "NAM preview discard received an invalid or incomplete target rack address"); + } + + // This addressed-rack check is diagnostic only. Physical deletion remains + // disabled until every live NAM rack can be enumerated or reference-counted. + const auto rackState = audioEngine.getBuiltInPluginState(trackId, chainType, fxIndex); + auto* stateObject = rackState.getDynamicObject(); + auto* modelState = stateObject != nullptr + ? stateObject->getProperty("modelState").getDynamicObject() + : nullptr; + if (modelState == nullptr) + { + return makeNAMPreviewRetentionResult( + "Could not verify the current NAM Rack state; the preview was kept"); + } + + const struct + { + const char* property; + const char* slot; + } modelPaths[] { + { "pedalModelPath", "pedal" }, + { "ampModelPath", "amp" }, + { "cabIRPath", "cab" }, + }; - return "OpenStudio shell startup self-test passed."; -} + for (const auto& candidate : modelPaths) + { + if (areNAMPathsEquivalent(localPath, modelState->getProperty(candidate.property).toString())) + { + return makeNAMPreviewRetentionResult( + "NAM preview is still loaded in the " + juce::String(candidate.slot) + " slot", + candidate.slot); + } + } -juce::String buildStartupSelfTestText(const StartupDependencyStatus& dependencyStatus) -{ - juce::StringArray lines; - lines.add("OpenStudio Startup Self-Test"); - lines.add("shellReady=" + juce::String(determineStartupFailureCategory(dependencyStatus) == "ready" ? "true" : "false")); - lines.add("failureCategory=" + determineStartupFailureCategory(dependencyStatus)); - lines.add("summary=" + buildStartupFailureSummary(dependencyStatus)); - lines.add("browserBackend=" + dependencyStatus.browserBackend); - lines.add("browserBackendSupported=" + juce::String(dependencyStatus.browserBackendSupported ? "true" : "false")); - lines.add("packagedFrontendPresent=" + juce::String(dependencyStatus.packagedFrontendPresent ? "true" : "false")); - lines.add("packagedFrontendPath=" + dependencyStatus.packagedFrontendPath); - lines.add("shellRuntimeAssetsPresent=" + juce::String(dependencyStatus.shellRuntimeAssetsPresent ? "true" : "false")); - lines.add("missingShellRuntimeAssets=" + dependencyStatus.missingShellRuntimeAssets.joinIntoString(" | ")); - lines.add("featureRuntimeAssetsPresent=" + juce::String(dependencyStatus.featureRuntimeAssetsPresent ? "true" : "false")); - lines.add("missingFeatureRuntimeAssets=" + dependencyStatus.missingFeatureRuntimeAssets.joinIntoString(" | ")); - lines.add("startupLogPath=" + getStartupLogFile().getFullPathName()); -#if JUCE_WINDOWS - lines.add("webView2RuntimeVersion=" + dependencyStatus.webView2RuntimeVersion); - lines.add("vcRedistInstalled=" + juce::String(dependencyStatus.vcRedistInstalled ? "true" : "false")); - lines.add("vcRedistVersion=" + dependencyStatus.vcRedistVersion); - lines.add("prerequisiteRepairAvailable=" + juce::String(dependencyStatus.repairAvailable ? "true" : "false")); -#endif - return lines.joinIntoString("\n"); -} + return makeNAMPreviewRetentionResult( + "The NAM preview was unloaded from the addressed rack but retained because other rack references cannot be proven clear"); } -juce::CriticalSection MainComponent::instanceListLock; -juce::Array<MainComponent*> MainComponent::activeInstances; - juce::var MainComponent::buildStartupSelfTestReport() { - const auto preferredBackend = getPreferredBrowserBackend(); - const auto supported = juce::WebBrowserComponent::areOptionsSupported( - juce::WebBrowserComponent::Options().withBackend(preferredBackend)); + const auto checkOptions = getEmbeddedBrowserBaseOptions(); + const auto supported = juce::WebBrowserComponent::areOptionsSupported(checkOptions); const auto dependencyStatus = evaluateStartupDependencies(supported); auto* report = new juce::DynamicObject(); @@ -1134,6 +8684,7 @@ juce::var MainComponent::buildStartupSelfTestReport() report->setProperty("missingFeatureRuntimeAssets", dependencyStatus.missingFeatureRuntimeAssets.joinIntoString("\n")); report->setProperty("startupLogPath", getStartupLogFile().getFullPathName()); #if JUCE_WINDOWS + report->setProperty("webView2UserDataPath", getWebView2UserDataFolder().getFullPathName()); report->setProperty("webView2RuntimeVersion", dependencyStatus.webView2RuntimeVersion); report->setProperty("vcRedistInstalled", dependencyStatus.vcRedistInstalled); report->setProperty("vcRedistVersion", dependencyStatus.vcRedistVersion); @@ -1173,16 +8724,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, windowRole(roleIn), windowInstanceId(windowInstanceIdIn), windowCallbacks(std::move(callbacksIn)), - webView (juce::WebBrowserComponent::Options() - .withBackend (getPreferredBrowserBackend()) -#if JUCE_WINDOWS - .withWinWebView2Options ( - juce::WebBrowserComponent::Options::WinWebView2() - .withUserDataFolder (juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory) - .getChildFile ("OpenStudio") - .getChildFile ("WebView2UserData")) - .withStatusBarDisabled()) -#endif + webView (getEmbeddedBrowserBaseOptions() .withNativeIntegrationEnabled() .withResourceProvider ([this] (const juce::String& path) -> std::optional<juce::WebBrowserComponent::Resource> { const auto requestedPath = path.upToFirstOccurrenceOf("?", false, false) @@ -1234,7 +8776,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::MessageManager::callAsync([safeThis, state, detail]() { - if (safeThis == nullptr) + if (safeThis == nullptr || safeThis->secondaryWindowClosing) return; if (state == "boot-started") @@ -1315,18 +8857,26 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, // Dialog functions are interactive — user may spend several minutes navigating. // Worker-backed startup calls may also legitimately take longer than the default bridge timeout. + // Plug-in scans enforce a per-candidate timeout natively, so do not add a second + // aggregate browser timeout that can expire while a healthy scan is still running. + // Offline renders are duration-dependent and continue on a native worker thread; + // a browser deadline would report failure while the valid output is still being written. // Use a 5-minute timeout for file choosers and AI generation startup, 15 seconds for everything else. - const DIALOG_FUNCTIONS = ['showRenderSaveDialog', 'showSaveDialog', 'showOpenDialog', 'showOpenFileDialog', 'showDirectoryDialog']; - const LONG_RUNNING_FUNCTIONS = ['startAIGeneration']; + const DIALOG_FUNCTIONS = ['showRenderSaveDialog', 'showSaveDialog', 'showOpenDialog', 'showOpenFileDialog', 'showDirectoryDialog', 'openAudioDeviceControlPanel']; + const LONG_RUNNING_FUNCTIONS = ['startAIGeneration', 'refreshNAMCatalog']; + const NO_TIMEOUT_FUNCTIONS = ['scanForPlugins', 'renderProject', 'renderProjectWithDither']; const timeoutMs = (DIALOG_FUNCTIONS.indexOf(name) >= 0 || LONG_RUNNING_FUNCTIONS.indexOf(name) >= 0) ? 300000 : 15000; - const timeout = setTimeout(() => { - window.__JUCE__.backend.removeEventListener(listener); - reject(new Error("Native function call timeout: " + name)); - }, timeoutMs); + const timeout = NO_TIMEOUT_FUNCTIONS.indexOf(name) >= 0 + ? null + : setTimeout(() => { + window.__JUCE__.backend.removeEventListener(listener); + reject(new Error("Native function call timeout: " + name)); + }, timeoutMs); const listener = window.__JUCE__.backend.addEventListener('__juce__complete', (data) => { if (data.promiseId === resultId) { - clearTimeout(timeout); + if (timeout !== null) + clearTimeout(timeout); window.__JUCE__.backend.removeEventListener(listener); resolve(data.result); } @@ -1350,12 +8900,35 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, console.log("JUCE User Script: Initialization complete. Available functions:", Object.keys(window.__JUCE__.backend)); )") - .withNativeFunction ("getAudioDeviceSetup", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { - juce::ignoreUnused(args); - // Return the current audio setup as a JSON object - completion (audioEngine.getAudioDeviceSetup()); - }) - .withNativeFunction ("setAudioDeviceSetup", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + .withNativeFunction ("getAudioDeviceSetup", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + // Return the current audio setup as a JSON object + completion (audioEngine.getAudioDeviceSetup()); + }) + .withNativeFunction ("getNAMRackOversamplingFactor", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + completion(audioEngine.getNAMRackOversamplingFactor()); + }) + .withNativeFunction ("setNAMRackOversamplingFactor", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() != 1) + { + completion(false); + return; + } + const int factor = static_cast<int>(args[0]); + juce::MessageManager::callAsync( + [this, factor, completion = std::move(completion)]() mutable { + completion(audioEngine.setNAMRackOversamplingFactor(factor)); + }); + }) + .withNativeFunction ("openAudioDeviceControlPanel", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + juce::MessageManager::callAsync( + [this, completion = std::move(completion)]() mutable { + completion(audioEngine.openAudioDeviceControlPanel()); + }); + }) + .withNativeFunction ("setAudioDeviceSetup", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { // Expecting: [type, input, output, sampleRate, bufferSize] if (args.size() == 1 && args[0].isObject()) { auto* obj = args[0].getDynamicObject(); @@ -1365,13 +8938,26 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, double sampleRate = obj->getProperty("sampleRate"); int bufferSize = obj->getProperty("bufferSize"); - // Call completion immediately to avoid timeout - // Audio device setup will happen in background - completion(true); - - // Run device setup asynchronously on message thread - juce::MessageManager::callAsync([this, type, input, output, sampleRate, bufferSize]() { - audioEngine.setAudioDeviceSetup(type, input, output, sampleRate, bufferSize); + // Device changes belong on the message thread. Resolve + // the JS promise only after JUCE has accepted (and + // verified) the actual setup so the UI cannot report a + // false success. + juce::MessageManager::callAsync([this, type, input, output, sampleRate, bufferSize, + completion = std::move(completion)]() mutable { + audioEngine.setAudioDeviceSetup( + type, + input, + output, + sampleRate, + bufferSize, + [completion = std::move(completion)]( + bool applied, + const juce::String& errorMessage) mutable { + if (! applied && errorMessage.isNotEmpty()) + juce::Logger::writeToLog( + "setAudioDeviceSetup failed: " + errorMessage); + completion(applied); + }); }); } else { completion(false); @@ -1380,6 +8966,14 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, .withNativeFunction ("reportFrontendStartupState", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { const auto state = args.size() > 0 ? args[0].toString().trim().toLowerCase() : juce::String(); const auto detail = args.size() > 1 ? args[1].toString() : juce::String(); + if (secondaryWindowClosing) + { + juce::Logger::writeToLog("Frontend startup report ignored after secondary close: state=" + state + + (detail.isNotEmpty() ? " detail=" + detail : "")); + completion(true); + return; + } + juce::Logger::writeToLog("Frontend startup report received via native function: state=" + state + (detail.isNotEmpty() ? " detail=" + detail : "")); @@ -1417,12 +9011,16 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, if (args.size() > 1 && args[1].isString()) { initialType = args[1].toString(); } + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); juce::String trackId = audioEngine.addTrack(explicitId, initialType); completion(trackId); }) .withNativeFunction ("removeTrack", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() > 0 && args[0].isString()) { + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool success = audioEngine.removeTrack(args[0].toString()); completion(success); } @@ -1468,6 +9066,18 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(false); } }) + .withNativeFunction ("setNAMTunerActive", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 2) { + const juce::String trackId = args[0].toString(); + const bool active = args[1]; + const juce::String subscriberId = + args.size() >= 3 ? args[2].toString() : juce::String(); + completion(audioEngine.setNAMTunerActive( + trackId, active, subscriberId)); + } else { + completion(false); + } + }) .withNativeFunction ("setTrackVolume", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 2) { juce::String trackId = args[0].toString(); @@ -1511,7 +9121,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, .withNativeFunction ("setTransportPlaying", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 1) { bool playing = args[0]; - logAudioBridge("setTransportPlaying playing=" + juce::String(playing ? "true" : "false")); + OPENSTUDIO_LOG_AUDIO_BRIDGE("setTransportPlaying playing=" + juce::String(playing ? "true" : "false")); audioEngine.setTransportPlaying(playing); completion(true); } else { @@ -1521,7 +9131,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, .withNativeFunction ("setTransportRecording", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 1) { bool recording = args[0]; - logAudioBridge("setTransportRecording recording=" + juce::String(recording ? "true" : "false")); + OPENSTUDIO_LOG_AUDIO_BRIDGE("setTransportRecording recording=" + juce::String(recording ? "true" : "false")); audioEngine.setTransportRecording(recording); completion(true); } else { @@ -1619,6 +9229,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, .withNativeFunction ("addMasterFX", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 1) { juce::String pluginPath = args[0].toString(); + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool success = audioEngine.addMasterFX(pluginPath); completion(success); } else { @@ -1631,8 +9243,20 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) .withNativeFunction ("removeMasterFX", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 1 && (args[0].isInt() || args[0].isDouble())) { - audioEngine.removeMasterFX((int)args[0]); - completion(true); + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); + completion(audioEngine.removeMasterFX((int)args[0])); + } else { + completion(false); + } + }) + .withNativeFunction ("reorderMasterFX", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 2 + && (args[0].isInt() || args[0].isDouble()) + && (args[1].isInt() || args[1].isDouble())) { + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); + completion(audioEngine.reorderMasterFX((int)args[0], (int)args[1])); } else { completion(false); } @@ -1695,14 +9319,65 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) // Plugin Management .withNativeFunction ("scanForPlugins", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const bool forceRescan = args.size() > 0 && static_cast<bool>(args[0]); + juce::Logger::writeToLog("MainComponent: scanForPlugins called from frontend" + + juce::String(forceRescan ? " (deep scan)" : "")); + if (pluginScanRunning.exchange(true, std::memory_order_acq_rel)) + { + auto* report = new juce::DynamicObject(); + report->setProperty("success", false); + report->setProperty("forceRescan", forceRescan); + report->setProperty("pluginCount", audioEngine.getAvailablePlugins().size()); + report->setProperty("candidateCount", 0); + report->setProperty("failedCount", 0); + report->setProperty("skippedCount", 0); + report->setProperty("paths", juce::Array<juce::var>()); + report->setProperty("failures", juce::Array<juce::var>()); + report->setProperty("skipped", juce::Array<juce::var>()); + report->setProperty("formats", juce::Array<juce::var>()); + report->setProperty("debugLogPath", juce::String()); + report->setProperty("error", "A plug-in scan is already running."); + completion(juce::var(report)); + return; + } + + juce::Component::SafePointer<MainComponent> safeThis(this); + pluginScanPool.addJob([safeThis, completion, forceRescan]() mutable { + if (safeThis == nullptr) + return; + + auto report = safeThis->audioEngine.scanForPlugins(forceRescan); + if (auto* reportObject = report.getDynamicObject()) + reportObject->setProperty("completionId", juce::Uuid().toString()); + + juce::MessageManager::callAsync([safeThis, completion, report]() mutable { + if (safeThis == nullptr) + return; + + safeThis->pluginScanRunning.store(false, std::memory_order_release); + completion(report); + MainComponent::broadcastEventToAll("pluginCatalogChanged", report); + }); + }); + }) + .withNativeFunction ("getPluginScanConfiguration", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); - juce::Logger::writeToLog("MainComponent: scanForPlugins called from frontend"); - audioEngine.scanForPlugins(); - int numPlugins = audioEngine.getAvailablePlugins().size(); - juce::String message = "Scan complete!\nFound " + juce::String(numPlugins) + " plugins."; - juce::AlertWindow::showMessageBoxAsync(juce::AlertWindow::InfoIcon, "Plugin Scan", message); - juce::Logger::writeToLog("MainComponent: Scan complete. Found " + juce::String(numPlugins) + " plugins"); - completion(true); + completion(audioEngine.getPluginScanConfiguration()); + }) + .withNativeFunction ("addPluginScanPath", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + completion(args.size() == 1 && args[0].isString() + ? audioEngine.addPluginScanPath(args[0].toString()) + : false); + }) + .withNativeFunction ("removePluginScanPath", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + completion(args.size() == 1 && args[0].isString() + ? audioEngine.removePluginScanPath(args[0].toString()) + : false); + }) + .withNativeFunction ("retryBlacklistedPlugin", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + completion(args.size() == 1 && args[0].isString() + ? audioEngine.retryBlacklistedPlugin(args[0].toString()) + : false); }) .withNativeFunction ("getAvailablePlugins", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); @@ -1713,6 +9388,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::String trackId = args[0].toString(); juce::String pluginPath = args[1].toString(); bool openEditor = args.size() >= 3 ? (bool)args[2] : true; + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool success = audioEngine.addTrackInputFX(trackId, pluginPath, openEditor); completion(success); } else { @@ -1724,6 +9401,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::String trackId = args[0].toString(); juce::String pluginPath = args[1].toString(); bool openEditor = args.size() >= 3 ? (bool)args[2] : true; + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool success = audioEngine.addTrackFX(trackId, pluginPath, openEditor); completion(success); } else { @@ -1767,14 +9446,117 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, auto fxIndex = static_cast<int>(args[1]); auto isInputFX = static_cast<bool>(args[2]); auto presetName = args[3].toString(); - completion(audioEngine.saveBuiltInFXPreset(trackId, fxIndex, isInputFX, presetName)); + const auto chainType = args.size() >= 5 + ? args[4].toString() + : (isInputFX ? juce::String("input") : juce::String("track")); + completion(audioEngine.saveBuiltInFXPreset(trackId, chainType, fxIndex, presetName)); }) .withNativeFunction ("loadBuiltInFXPreset", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { auto trackId = args[0].toString(); auto fxIndex = static_cast<int>(args[1]); auto isInputFX = static_cast<bool>(args[2]); auto presetName = args[3].toString(); - completion(audioEngine.loadBuiltInFXPreset(trackId, fxIndex, isInputFX, presetName)); + const auto chainType = args.size() >= 5 + ? args[4].toString() + : (isInputFX ? juce::String("input") : juce::String("track")); + std::vector<std::pair<juce::String, juce::uint64>> namMutationRequests; + const auto topologyGeneration = beginNAMModelMutationRequests( + trackId, + chainType, + fxIndex, + { "pedal", "amp", "cab" }, + namMutationRequests); + + juce::Component::SafePointer<MainComponent> safeThis(this); + builtInStateMutationPool.addJob([ + safeThis, + trackId, + chainType, + fxIndex, + presetName, + namMutationRequests, + topologyGeneration, + completion]() mutable { + if (safeThis == nullptr) + return; + + bool stillCurrent = isNAMRackTopologyCurrent(topologyGeneration); + for (const auto& request : namMutationRequests) + { + stillCurrent = stillCurrent + && safeThis->isNAMModelMutationRequestCurrent( + trackId, chainType, fxIndex, request.first, request.second); + } + if (! stillCurrent) + { + juce::MessageManager::callAsync([safeThis, completion]() mutable { + if (safeThis != nullptr) + completion(false); + }); + return; + } + + const auto publicationLeaseFactory = [ + safeThis, + trackId, + chainType, + fxIndex, + namMutationRequests, + topologyGeneration]() + { + return safeThis != nullptr + ? safeThis->acquireNAMModelMutationPublicationLease( + trackId, + chainType, + fxIndex, + namMutationRequests, + topologyGeneration) + : std::shared_ptr<void>(); + }; + const bool applied = safeThis->audioEngine.loadBuiltInFXPreset( + trackId, + chainType, + fxIndex, + presetName, + publicationLeaseFactory); + // A successful publication lease proves this request + // was current at the audible swap. A request begun + // afterward must not retroactively turn success false. + const bool result = applied; + juce::MessageManager::callAsync([safeThis, completion, result]() mutable { + if (safeThis != nullptr) + completion(result); + }); + }); + }) + .withNativeFunction ("getBuiltInFXPresetData", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() != 2) + { + completion(juce::String()); + return; + } + completion(audioEngine.getBuiltInFXPresetData( + args[0].toString(), args[1].toString())); + }) + .withNativeFunction ("saveBuiltInFXPresetData", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() != 3) + { + completion(false); + return; + } + completion(audioEngine.saveBuiltInFXPresetData( + args[0].toString(), args[1].toString(), args[2].toString())); + }) + .withNativeFunction ("copyBuiltInFXPreset", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() != 3) + { + completion(false); + return; + } + const auto pluginName = args[0].toString(); + const auto sourcePresetName = args[1].toString(); + const auto targetPresetName = args[2].toString(); + completion(audioEngine.copyBuiltInFXPreset(pluginName, sourcePresetName, targetPresetName)); }) .withNativeFunction ("deleteBuiltInFXPreset", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { auto pluginName = args[0].toString(); @@ -1815,6 +9597,13 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(juce::var()); } }) + .withNativeFunction ("getNAMRackDiagnostics", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 3 && args[0].isString()) { + completion(audioEngine.getNAMRackDiagnostics(args[0].toString(), args[1].toString(), static_cast<int>(args[2]))); + } else { + completion(juce::var()); + } + }) .withNativeFunction ("getBuiltInPluginState", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 3 && args[0].isString()) { completion(audioEngine.getBuiltInPluginState(args[0].toString(), args[1].toString(), static_cast<int>(args[2]))); @@ -1832,7 +9621,70 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) .withNativeFunction ("setBuiltInPluginState", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 4 && args[0].isString()) { - completion(audioEngine.setBuiltInPluginState(args[0].toString(), args[1].toString(), static_cast<int>(args[2]), args[3].toString())); + const auto trackId = args[0].toString(); + const auto chainType = args[1].toString(); + const int fxIndex = static_cast<int>(args[2]); + const auto stateJson = args[3].toString(); + const auto namMutationSlots = getNAMModelMutationSlots(stateJson); + std::vector<std::pair<juce::String, juce::uint64>> namMutationRequests; + const auto topologyGeneration = beginNAMModelMutationRequests( + trackId, chainType, fxIndex, namMutationSlots, namMutationRequests); + + juce::Component::SafePointer<MainComponent> safeThis(this); + builtInStateMutationPool.addJob([safeThis, trackId, chainType, fxIndex, stateJson, namMutationRequests, topologyGeneration, completion]() mutable { + if (safeThis == nullptr) + return; + + if (! isNAMRackTopologyCurrent(topologyGeneration)) + { + juce::Logger::writeToLog( + "Built-in bridge: skipped state mutation after FX topology changed"); + juce::MessageManager::callAsync([safeThis, completion]() mutable { + if (safeThis != nullptr) + completion(false); + }); + return; + } + + for (const auto& request : namMutationRequests) + { + if (! safeThis->isNAMModelMutationRequestCurrent( + trackId, chainType, fxIndex, request.first, request.second)) + { + juce::Logger::writeToLog( + "Built-in bridge: skipped superseded NAM-bearing state mutation slot=" + + request.first); + juce::MessageManager::callAsync([safeThis, completion]() mutable { + if (safeThis != nullptr) + completion(false); + }); + return; + } + } + + juce::Logger::writeToLog("Built-in bridge: sequenced state mutation started chain=" + chainType + " fx=" + juce::String(fxIndex)); + const auto publicationLeaseFactory = [safeThis, + trackId, + chainType, + fxIndex, + namMutationRequests, + topologyGeneration]() + { + return safeThis != nullptr + ? safeThis->acquireNAMModelMutationPublicationLease( + trackId, chainType, fxIndex, + namMutationRequests, topologyGeneration) + : std::shared_ptr<void>(); + }; + const bool applied = safeThis->audioEngine.setBuiltInPluginState( + trackId, chainType, fxIndex, stateJson, publicationLeaseFactory); + const bool result = applied; + juce::Logger::writeToLog("Built-in bridge: sequenced state mutation finished chain=" + chainType + " fx=" + juce::String(fxIndex) + " result=" + juce::String(result ? "true" : "false")); + juce::MessageManager::callAsync([safeThis, completion, result]() mutable { + if (safeThis != nullptr) + completion(result); + }); + }); } else { completion(false); } @@ -1851,18 +9703,20 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) .withNativeFunction ("removeTrackInputFX", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 2 && args[1].isInt()) { - juce::String trackId = args[0].toString(); - audioEngine.removeTrackInputFX(trackId, args[1]); - completion(true); + juce::String trackId = args[0].toString(); + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); + completion(audioEngine.removeTrackInputFX(trackId, args[1])); } else { completion(false); } }) .withNativeFunction ("removeTrackFX", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 2 && args[1].isInt()) { - juce::String trackId = args[0].toString(); - audioEngine.removeTrackFX(trackId, args[1]); - completion(true); + juce::String trackId = args[0].toString(); + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); + completion(audioEngine.removeTrackFX(trackId, args[1])); } else { completion(false); } @@ -1890,6 +9744,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::String trackId = args[0].toString(); int fromIndex = args[1]; int toIndex = args[2]; + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool success = audioEngine.reorderTrackInputFX(trackId, fromIndex, toIndex); completion(success); } else { @@ -1901,6 +9757,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::String trackId = args[0].toString(); int fromIndex = args[1]; int toIndex = args[2]; + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool success = audioEngine.reorderTrackFX(trackId, fromIndex, toIndex); completion(success); } else { @@ -1913,6 +9771,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::String trackId = args[0].toString(); juce::String scriptPath = args[1].toString(); bool isInputFX = args.size() >= 3 ? (bool)args[2] : false; + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool success = audioEngine.addTrackS13FX(trackId, scriptPath, isInputFX); completion(success); } else { @@ -1922,6 +9782,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, .withNativeFunction ("addMasterS13FX", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 1) { juce::String scriptPath = args[0].toString(); + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool success = audioEngine.addMasterS13FX(scriptPath); completion(success); } else { @@ -2014,7 +9876,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, .withNativeFunction ("setTransportPosition", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 1) { double seconds = args[0]; - logAudioBridge("setTransportPosition seconds=" + juce::String(seconds, 3)); + OPENSTUDIO_LOG_AUDIO_BRIDGE("setTransportPosition seconds=" + juce::String(seconds, 3)); audioEngine.setTransportPosition(seconds); completion(true); } else { @@ -2099,7 +9961,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, // Recording .withNativeFunction ("getLastCompletedClips", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); - logAudioBridge("getLastCompletedClips"); + OPENSTUDIO_LOG_AUDIO_BRIDGE("getLastCompletedClips"); auto clips = audioEngine.getLastCompletedClips(); juce::Array<juce::var> clipArray; @@ -2191,6 +10053,38 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(juce::Array<juce::var>()); } }) + .withNativeFunction ("getAudioPeakAmplitude", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() != 3 + || ! args[0].isString() + || (! args[1].isDouble() && ! args[1].isInt()) + || (! args[2].isDouble() && ! args[2].isInt())) + { + completion(-1.0); + return; + } + + const juce::String filePath = args[0].toString(); + const double offsetSeconds = static_cast<double>(args[1]); + const double durationSeconds = static_cast<double>(args[2]); + juce::Component::SafePointer<MainComponent> safeThis(this); + clipPeakAnalysisPool.addJob([ + safeThis, + filePath, + offsetSeconds, + durationSeconds, + completion]() mutable { + if (safeThis == nullptr) + return; + const double peak = safeThis->audioEngine.getAudioPeakAmplitude( + filePath, + offsetSeconds, + durationSeconds); + juce::MessageManager::callAsync([safeThis, completion, peak]() mutable { + if (safeThis != nullptr) + completion(peak); + }); + }); + }) .withNativeFunction ("refreshWaveformPeaks", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 1) { completion(audioEngine.refreshWaveformPeaks(args[0].toString())); @@ -2199,14 +10093,19 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, } }) .withNativeFunction ("getRecordingPeaks", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { - if (args.size() == 3) { + if (args.size() >= 3) { juce::String trackId = args[0].toString(); int samplesPerPixel = args[1]; int numPixels = args[2]; - logAudioBridge("getRecordingPeaks track=" + trackId + juce::int64 startSample = args.size() >= 4 + ? static_cast<juce::int64>(static_cast<double>(args[3])) + : 0; + OPENSTUDIO_LOG_AUDIO_BRIDGE("getRecordingPeaks track=" + trackId + " samplesPerPixel=" + juce::String(samplesPerPixel) - + " numPixels=" + juce::String(numPixels)); - completion(audioEngine.getRecordingPeaks(trackId, samplesPerPixel, numPixels)); + + " numPixels=" + juce::String(numPixels) + + " startSample=" + juce::String(startSample)); + completion(audioEngine.getRecordingPeaks( + trackId, samplesPerPixel, numPixels, startSample)); } else { completion(juce::Array<juce::var>()); } @@ -2227,7 +10126,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::String clipId = args.size() > 8 ? args[8].toString() : juce::String(); juce::String pitchCorrectionSourceFilePath = args.size() > 9 ? args[9].toString() : juce::String(); double pitchCorrectionSourceOffset = args.size() > 10 ? (double)args[10] : -1.0; - logAudioBridge("addPlaybackClip track=" + trackId + OPENSTUDIO_LOG_AUDIO_BRIDGE("addPlaybackClip track=" + trackId + " clipId=" + clipId + " file=" + filePath + " start=" + juce::String(startTime, 3) @@ -2245,6 +10144,16 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::String filePath = args[1].toString(); audioEngine.removePlaybackClip(trackId, filePath); completion(true); + } else { + completion(false); + } + }) + .withNativeFunction ("removePlaybackClipById", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 2 && args[1].isString()) { + juce::String trackId = args[0].toString(); + juce::String clipId = args[1].toString(); + audioEngine.removePlaybackClipById(trackId, clipId); + completion(true); } else { completion(false); } @@ -2252,7 +10161,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, .withNativeFunction ("addPlaybackClipsBatch", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 1 && args[0].isString()) { - logAudioBridge("addPlaybackClipsBatch"); + OPENSTUDIO_LOG_AUDIO_BRIDGE("addPlaybackClipsBatch"); audioEngine.addPlaybackClipsBatch (args[0].toString()); completion (true); } @@ -2261,7 +10170,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) .withNativeFunction ("clearPlaybackClips", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); - logAudioBridge("clearPlaybackClips"); + OPENSTUDIO_LOG_AUDIO_BRIDGE("clearPlaybackClips"); audioEngine.clearPlaybackClips(); completion(true); }) @@ -2416,9 +10325,12 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) .withNativeFunction ("getAudioDebugSnapshot", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { juce::ignoreUnused(args); - logAudioBridge("getAudioDebugSnapshot"); completion(audioEngine.getAudioDebugSnapshot()); }) + .withNativeFunction ("getRealtimeAudioTelemetry", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + completion(audioEngine.getRealtimeAudioTelemetry()); + }) .withNativeFunction ("getPluginCapabilities", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() == 1 && args[0].isString()) { completion(audioEngine.getPluginCapabilities(args[0].toString())); @@ -2519,7 +10431,420 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, return; } - completion(juce::URL(args[0].toString()).launchInDefaultBrowser()); + const auto url = args[0].toString().trim(); + if (! isAllowedExternalBrowserURL(url)) + { + completion(false); + return; + } + + completion(juce::URL(url).launchInDefaultBrowser()); + }) + .withNativeFunction ("revealLocalPath", [] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() == 0 || ! args[0].isString()) + { + completion(false); + return; + } + + const auto path = args[0].toString(); + if (path.trim().isEmpty() + || path.containsAnyOf("\r\n") + || ! juce::File::isAbsolutePath(path)) + { + completion(false); + return; + } + + const juce::File localPath(path); + if (! localPath.existsAsFile() && ! localPath.isDirectory()) + { + completion(false); + return; + } + + // revealToUser opens the containing file manager and never + // executes the selected file. + localPath.revealToUser(); + completion(true); + }) + .withNativeFunction ("createTONE3000AuthRequest", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto clientId = args.size() > 0 ? args[0].toString() : juce::String(); + const auto redirectUri = args.size() > 1 ? args[1].toString() : juce::String(); + const auto prompt = args.size() > 2 ? args[2].toString() : juce::String(); + const auto toneId = args.size() > 3 ? args[3].toString() : juce::String(); + const auto loginHint = args.size() > 4 ? args[4].toString() : juce::String(); + runTone3000NativeTask( + [clientId, redirectUri, prompt, toneId, loginHint] + { + return createTone3000AuthRequest( + clientId, + redirectUri, + prompt, + toneId, + loginHint); + }, + std::move(completion)); + }) + .withNativeFunction ("startTONE3000AuthFlow", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto options = args.size() > 0 ? args[0] : juce::var(); + runTone3000NativeTask( + [options] + { + return startTone3000AuthFlow(options); + }, + std::move(completion)); + }) + .withNativeFunction ("cancelTONE3000AuthFlow", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + runTone3000NativeTask( + [] { return cancelTone3000AuthFlow(); }, + std::move(completion)); + }) + .withNativeFunction ("exchangeTONE3000OAuthCode", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto code = args.size() > 0 ? args[0].toString() : juce::String(); + const auto state = args.size() > 1 ? args[1].toString() : juce::String(); + const auto clientId = args.size() > 2 ? args[2].toString() : juce::String(); + const auto redirectUri = args.size() > 3 ? args[3].toString() : juce::String(); + runTone3000NativeTask( + [code, state, clientId, redirectUri] + { + juce::Logger::writeToLog("TONE3000 bridge: exchangeOAuthCode started"); + auto result = exchangeTone3000OAuthCode(code, state, clientId, redirectUri); + juce::Logger::writeToLog("TONE3000 bridge: exchangeOAuthCode finished"); + return result; + }, + std::move(completion)); + }) + .withNativeFunction ("refreshTONE3000Auth", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto clientId = args.size() > 0 ? args[0].toString() : juce::String(); + runTone3000NativeTask( + [clientId] + { + juce::Logger::writeToLog("TONE3000 bridge: refreshAuth started"); + auto result = refreshTone3000Auth(clientId); + juce::Logger::writeToLog("TONE3000 bridge: refreshAuth finished"); + return result; + }, + std::move(completion)); + }) + .withNativeFunction ("getTONE3000AuthStatus", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + runTone3000NativeTask( + [] { return makeTone3000AuthStatus(); }, + std::move(completion)); + }) + .withNativeFunction ("clearTONE3000Auth", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + runTone3000NativeTask( + [] { return clearTone3000Auth(); }, + std::move(completion)); + }) + .withNativeFunction ("getNAMLibraryInfo", [] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + completion(makeNAMLibraryInfo()); + }) + .withNativeFunction ("inspectNAMAsset", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto filePath = args.size() > 0 ? args[0].toString() : juce::String(); + runTone3000NativeTask( + [filePath] + { + #if JUCE_WINDOWS + ::SetThreadPriority( + ::GetCurrentThread(), + THREAD_PRIORITY_BELOW_NORMAL); + #endif + return inspectNAMAssetFile(filePath); + }, + std::move(completion)); + }) + .withNativeFunction ("findNAMAssetInDirectory", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto directoryPath = args.size() > 0 ? args[0].toString() : juce::String(); + const auto expectedFileName = args.size() > 1 ? args[1].toString() : juce::String(); + const auto checksum = args.size() > 2 ? args[2].toString() : juce::String(); + const auto fileSizeBytes = args.size() > 3 ? static_cast<juce::int64>(static_cast<double>(args[3])) : 0; + const auto slot = args.size() > 4 ? args[4].toString().trim().toLowerCase() : juce::String("amp"); + runTone3000NativeTask( + [directoryPath, expectedFileName, checksum, fileSizeBytes, slot] + { + return findNAMAssetInDirectory( + directoryPath, + expectedFileName, + checksum, + fileSizeBytes, + slot); + }, + std::move(completion)); + }) + .withNativeFunction ("getNAMCatalog", [] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + completion(parseJsonFileOrDefault(getOpenStudioNAMCatalogJson(), "tones")); + }) + .withNativeFunction ("refreshNAMCatalog", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto options = args.size() > 0 ? args[0] : juce::var(); + runTone3000NativeTask( + [options] + { + return refreshNAMCatalogFromUpdater(options); + }, + std::move(completion)); + }) + .withNativeFunction ("searchTONE3000NAM", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto options = args.size() > 0 ? args[0] : juce::var(); + runTone3000NativeTask( + [options] + { + juce::Logger::writeToLog("TONE3000 bridge: searchNAM started"); + auto result = searchTone3000NAM(options); + juce::Logger::writeToLog("TONE3000 bridge: searchNAM finished"); + return result; + }, + std::move(completion)); + }) + .withNativeFunction ("runTONE3000AuthenticatedQA", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + runTone3000NativeTask( + [] { return runTone3000AuthenticatedQA(); }, + std::move(completion)); + }) + .withNativeFunction ("getTONE3000ToneDetail", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const int toneId = args.size() > 0 ? static_cast<int>(args[0]) : 0; + const auto architecture = args.size() > 1 ? args[1].toString() : juce::String(); + runTone3000NativeTask( + [toneId, architecture] + { + juce::Logger::writeToLog("TONE3000 bridge: toneDetail started toneId=" + juce::String(toneId)); + auto result = getTone3000ToneDetail(toneId, architecture); + juce::Logger::writeToLog("TONE3000 bridge: toneDetail finished toneId=" + juce::String(toneId)); + return result; + }, + std::move(completion)); + }) + .withNativeFunction ("getNAMLibrary", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::ignoreUnused(args); + runTone3000NativeTask( + [] + { + juce::Logger::writeToLog("TONE3000 bridge: getNAMLibrary started"); + auto result = refreshNAMLibraryManifest(true); + juce::Logger::writeToLog("TONE3000 bridge: getNAMLibrary finished"); + return result; + }, + std::move(completion)); + }) + .withNativeFunction ("installNAMModel", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() < 1) + { + juce::DynamicObject::Ptr result = new juce::DynamicObject(); + result->setProperty("success", false); + result->setProperty("error", "Missing NAM model metadata"); + completion(juce::var(result.get())); + return; + } + const auto modelPayload = args[0]; + const auto optionsPayload = args.size() > 1 ? args[1] : juce::var(); + runTone3000NativeTask( + [modelPayload, optionsPayload] + { + bool previewMode = false; + if (optionsPayload.isString()) + { + const auto options = juce::JSON::parse(optionsPayload.toString()); + if (auto* object = options.getDynamicObject()) + previewMode = object->getProperty("mode").toString() == "preview"; + } + else if (auto* object = optionsPayload.getDynamicObject()) + { + previewMode = object->getProperty("mode").toString() == "preview"; + } + + juce::Logger::writeToLog("TONE3000 bridge: installNAMModel started mode=" + juce::String(previewMode ? "preview" : "library")); + auto result = installNAMModelFromMetadata(modelPayload, previewMode); + juce::Logger::writeToLog("TONE3000 bridge: installNAMModel finished"); + return result; + }, + std::move(completion)); + }) + .withNativeFunction ("commitNAMPreviewTone", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto recordPayload = args.size() > 0 ? args[0] : juce::var(); + const auto metadataPayload = args.size() > 1 ? args[1] : juce::var(); + const auto rackStatePayload = args.size() > 2 ? args[2] : juce::var(); + runTone3000NativeTask( + [recordPayload, metadataPayload, rackStatePayload] + { + juce::Logger::writeToLog("TONE3000 bridge: commitNAMPreviewTone started"); + auto result = commitNAMPreviewToneToLibrary(recordPayload, metadataPayload, rackStatePayload); + juce::Logger::writeToLog("TONE3000 bridge: commitNAMPreviewTone finished"); + return result; + }, + std::move(completion)); + }) + .withNativeFunction ("discardNAMPreview", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const auto recordPayload = args.size() > 0 ? args[0] : juce::var(); + const auto rackAddressPayload = args.size() > 1 ? args[1] : juce::var(); + juce::Component::SafePointer<MainComponent> safeThis(this); + builtInStateMutationPool.addJob([safeThis, recordPayload, rackAddressPayload, completion]() mutable { + if (safeThis == nullptr) + return; + + auto result = safeThis->discardNAMPreviewIfUnused(recordPayload, rackAddressPayload); + juce::MessageManager::callAsync([safeThis, completion, result]() { + if (safeThis != nullptr) + completion(result); + }); + }); + }) + .withNativeFunction ("cleanupNAMPreviews", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const double maxAgeHours = args.size() > 0 ? static_cast<double>(args[0]) : 24.0; + runTone3000NativeTask( + [maxAgeHours] + { + return cleanupNAMPreviewFiles(maxAgeHours); + }, + std::move(completion)); + }) + .withNativeFunction ("setNAMModelFavorite", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const int modelId = args.size() > 0 ? static_cast<int>(args[0]) : 0; + const auto localPath = args.size() > 1 ? args[1].toString() : juce::String(); + const bool favorite = args.size() > 2 && static_cast<bool>(args[2]); + runTone3000NativeTask( + [modelId, localPath, favorite] + { + return setNAMLibraryFavorite( + modelId, localPath, favorite); + }, + std::move(completion)); + }) + .withNativeFunction ("removeNAMModel", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + const int modelId = args.size() > 0 ? static_cast<int>(args[0]) : 0; + const auto localPath = args.size() > 1 ? args[1].toString() : juce::String(); + const bool deleteLocalFile = args.size() > 2 && static_cast<bool>(args[2]); + runTone3000NativeTask( + [modelId, localPath, deleteLocalFile] + { + return removeNAMModelFromLibrary( + modelId, + localPath, + deleteLocalFile); + }, + std::move(completion)); + }) + .withNativeFunction ("loadNAMModelIntoRack", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() < 5) + { + completion(false); + return; + } + + const auto trackId = args[0].toString(); + const auto chainType = args[1].toString(); + const int fxIndex = static_cast<int>(args[2]); + const auto slot = args[3].toString().trim().toLowerCase(); + const auto localPath = args[4].toString(); + if (slot != "pedal" && slot != "amp" && slot != "cab") + { + completion(false); + return; + } + + juce::StringArray mutationSlots; + mutationSlots.add(slot); + std::vector<std::pair<juce::String, juce::uint64>> mutationRequests; + const auto topologyGeneration = beginNAMModelMutationRequests( + trackId, chainType, fxIndex, mutationSlots, mutationRequests); + const auto requestGeneration = mutationRequests.front().second; + juce::Component::SafePointer<MainComponent> safeThis(this); + builtInStateMutationPool.addJob([safeThis, trackId, chainType, fxIndex, slot, localPath, requestGeneration, topologyGeneration, completion]() mutable { + if (safeThis == nullptr) + return; + + const auto completeSafely = [safeThis, completion](bool result) { + juce::MessageManager::callAsync([safeThis, completion, result]() { + if (safeThis != nullptr) + completion(result); + }); + }; + + if (! isNAMRackTopologyCurrent(topologyGeneration)) + { + juce::Logger::writeToLog( + "TONE3000 bridge: skipped NAM model request after FX topology changed slot=" + slot); + completeSafely(false); + return; + } + + if (! safeThis->isNAMModelMutationRequestCurrent(trackId, chainType, fxIndex, slot, requestGeneration)) + { + juce::Logger::writeToLog("TONE3000 bridge: skipped superseded NAM model request slot=" + slot); + completeSafely(false); + return; + } + + juce::Logger::writeToLog("TONE3000 bridge: loadNAMModelIntoRack started slot=" + slot); + juce::DynamicObject::Ptr state = new juce::DynamicObject(); + if (slot == "pedal") + { + if (localPath.isNotEmpty()) + { + state->setProperty("pedalModelPath", localPath); + // A fresh user-selected NAM should publish its + // highest-fidelity graph. Explicit preset/project + // recalls still carry and preserve their saved size. + state->setProperty("pedalModelSize", 1.0); + } + else + state->setProperty("clearPedalModel", true); + } + else if (slot == "cab") + { + if (localPath.isNotEmpty()) + state->setProperty("cabIRPath", localPath); + else + state->setProperty("clearCabIR", true); + } + else + { + if (localPath.isNotEmpty()) + { + state->setProperty("ampModelPath", localPath); + state->setProperty("ampModelSize", 1.0); + } + else + state->setProperty("clearAmpModel", true); + } + + const auto publicationLeaseFactory = [safeThis, + trackId, + chainType, + fxIndex, + slot, + requestGeneration, + topologyGeneration]() + { + if (safeThis == nullptr) + return std::shared_ptr<void>(); + std::vector<std::pair<juce::String, juce::uint64>> requests { + { slot, requestGeneration } + }; + return safeThis->acquireNAMModelMutationPublicationLease( + trackId, chainType, fxIndex, requests, topologyGeneration); + }; + const bool applied = safeThis->audioEngine.setBuiltInPluginState( + trackId, + chainType, + fxIndex, + juce::JSON::toString(juce::var(state.get()), false), + publicationLeaseFactory); + const bool supersededAfterPublish = ! safeThis->isNAMModelMutationRequestCurrent( + trackId, chainType, fxIndex, slot, requestGeneration); + const bool result = applied; + juce::Logger::writeToLog("TONE3000 bridge: loadNAMModelIntoRack finished slot=" + slot + + " result=" + juce::String(result ? "true" : "false") + + (supersededAfterPublish + ? juce::String(" supersededAfterPublish=true") + : juce::String())); + completeSafely(result); + }); }) .withNativeFunction ("browseForFile", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { const auto title = args.size() > 0 && args[0].isString() @@ -2601,6 +10926,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, if (!lowerPath.endsWith(preferredExtension) && !lowerPath.endsWith(".s13") && !lowerPath.endsWith(".s13preset") + && !lowerPath.endsWith(".s13nampreset") && !lowerPath.endsWith(".s13theme")) { path += preferredExtension; @@ -2642,19 +10968,27 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, // Save project JSON to file // Args: [filePath, jsonContent] if (args.size() == 2 && args[0].isString() && args[1].isString()) { - juce::String filePath = args[0].toString(); - juce::String jsonContent = args[1].toString(); - - juce::File file(filePath); - bool success = file.replaceWithText(jsonContent); - - if (success) { - juce::Logger::writeToLog("Project saved to: " + filePath); - } else { - juce::Logger::writeToLog("Failed to save project to: " + filePath); - } - - completion(success); + const juce::String filePath = args[0].toString(); + const juce::String jsonContent = args[1].toString(); + std::thread( + [filePath, jsonContent, completion]() mutable + { + #if JUCE_WINDOWS + ::SetThreadPriority( + ::GetCurrentThread(), + THREAD_PRIORITY_BELOW_NORMAL); + #endif + const bool success = + juce::File(filePath) + .replaceWithText( + jsonContent); + juce::MessageManager::callAsync( + [completion, success]() + { + completion(success); + }); + }) + .detach(); } else { completion(false); } @@ -2731,12 +11065,68 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, // Set plugin state from base64 string // Args: [trackId, fxIndex, isInputFX, base64State] if (args.size() == 4) { - juce::String trackId = args[0].toString(); - int fxIndex = args[1]; - bool isInputFX = args[2]; - juce::String base64State = args[3].toString(); - bool success = audioEngine.setPluginState(trackId, fxIndex, isInputFX, base64State); - completion(success); + const auto trackId = args[0].toString(); + const auto fxIndex = static_cast<int>(args[1]); + const auto isInputFX = static_cast<bool>(args[2]); + const auto base64State = args[3].toString(); + if (! audioEngine.isNAMRackPlugin(trackId, fxIndex, isInputFX)) + { + completion(audioEngine.setPluginState( + trackId, fxIndex, isInputFX, base64State)); + return; + } + + const auto chainType = isInputFX + ? juce::String("input") : juce::String("track"); + std::vector<std::pair<juce::String, juce::uint64>> namMutationRequests; + const auto topologyGeneration = beginNAMModelMutationRequests( + trackId, chainType, fxIndex, + { "pedal", "amp", "cab" }, namMutationRequests); + juce::Component::SafePointer<MainComponent> safeThis(this); + builtInStateMutationPool.addJob([ + safeThis, trackId, chainType, fxIndex, isInputFX, + base64State, namMutationRequests, topologyGeneration, + completion]() mutable { + if (safeThis == nullptr) + return; + + bool stillCurrent = isNAMRackTopologyCurrent(topologyGeneration); + for (const auto& request : namMutationRequests) + { + stillCurrent = stillCurrent + && safeThis->isNAMModelMutationRequestCurrent( + trackId, chainType, fxIndex, + request.first, request.second); + } + if (! stillCurrent) + { + juce::MessageManager::callAsync([safeThis, completion]() mutable { + if (safeThis != nullptr) + completion(false); + }); + return; + } + + const auto publicationLeaseFactory = [ + safeThis, trackId, chainType, fxIndex, + namMutationRequests, topologyGeneration]() + { + return safeThis != nullptr + ? safeThis->acquireNAMModelMutationPublicationLease( + trackId, chainType, fxIndex, + namMutationRequests, topologyGeneration) + : std::shared_ptr<void>(); + }; + const bool applied = safeThis->audioEngine.setPluginState( + trackId, fxIndex, isInputFX, base64State, + publicationLeaseFactory); + const bool result = applied; + juce::MessageManager::callAsync([ + safeThis, completion, result]() mutable { + if (safeThis != nullptr) + completion(result); + }); + }); } else { completion(false); } @@ -2756,10 +11146,60 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, // Set master FX plugin state from base64 // Args: [fxIndex, base64State] if (args.size() == 2) { - int fxIndex = args[0]; - juce::String base64State = args[1].toString(); - bool success = audioEngine.setMasterPluginState(fxIndex, base64State); - completion(success); + const auto fxIndex = static_cast<int>(args[0]); + const auto base64State = args[1].toString(); + if (! audioEngine.isMasterNAMRackPlugin(fxIndex)) + { + completion(audioEngine.setMasterPluginState(fxIndex, base64State)); + return; + } + + std::vector<std::pair<juce::String, juce::uint64>> namMutationRequests; + const auto topologyGeneration = beginNAMModelMutationRequests( + {}, "master", fxIndex, + { "pedal", "amp", "cab" }, namMutationRequests); + juce::Component::SafePointer<MainComponent> safeThis(this); + builtInStateMutationPool.addJob([ + safeThis, fxIndex, base64State, namMutationRequests, + topologyGeneration, completion]() mutable { + if (safeThis == nullptr) + return; + + bool stillCurrent = isNAMRackTopologyCurrent(topologyGeneration); + for (const auto& request : namMutationRequests) + { + stillCurrent = stillCurrent + && safeThis->isNAMModelMutationRequestCurrent( + {}, "master", fxIndex, + request.first, request.second); + } + if (! stillCurrent) + { + juce::MessageManager::callAsync([safeThis, completion]() mutable { + if (safeThis != nullptr) + completion(false); + }); + return; + } + + const auto publicationLeaseFactory = [ + safeThis, fxIndex, namMutationRequests, + topologyGeneration]() + { + return safeThis != nullptr + ? safeThis->acquireNAMModelMutationPublicationLease( + {}, "master", fxIndex, + namMutationRequests, topologyGeneration) + : std::shared_ptr<void>(); + }; + const bool result = safeThis->audioEngine.setMasterPluginState( + fxIndex, base64State, publicationLeaseFactory); + juce::MessageManager::callAsync([ + safeThis, completion, result]() mutable { + if (safeThis != nullptr) + completion(result); + }); + }); } else { completion(false); } @@ -2864,21 +11304,38 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, tempDir.createDirectory(); extractedFile = tempDir.getChildFile(audioFile.getFileNameWithoutExtension() + "_audio.wav"); - // Find FFmpeg: check next to executable first, then fall back to PATH - juce::File appDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory(); - juce::File bundledFFmpeg = appDir.getChildFile("ffmpeg.exe"); - juce::String ffmpegPath = bundledFFmpeg.existsAsFile() ? bundledFFmpeg.getFullPathName() : "ffmpeg"; - - // Run FFmpeg to extract audio as WAV - juce::String cmd = "\"" + ffmpegPath + "\" -y -i \"" + filePath + "\" -vn -acodec pcm_s16le -ar 44100 -ac 2 \"" + extractedFile.getFullPathName() + "\""; + const auto ffmpegExecutable = + OpenStudioFFmpeg::findExecutable(); + juce::StringArray processArgs; + processArgs.add( + ffmpegExecutable.getFullPathName()); + processArgs.add("-y"); + processArgs.add("-i"); + processArgs.add(filePath); + processArgs.add("-vn"); + processArgs.add("-acodec"); + processArgs.add("pcm_s16le"); + processArgs.add("-ar"); + processArgs.add("44100"); + processArgs.add("-ac"); + processArgs.add("2"); + processArgs.add( + extractedFile.getFullPathName()); juce::ChildProcess ffmpeg; - bool started = ffmpeg.start(cmd); + const bool started = + ffmpegExecutable.existsAsFile() + && ffmpeg.start(processArgs); if (started) { // Wait up to 60 seconds for extraction - bool finished = ffmpeg.waitForProcessToFinish(60000); - auto exitCode = ffmpeg.getExitCode(); + const bool finished = + ffmpeg.waitForProcessToFinish(60000); + if (! finished) + ffmpeg.kill(); + const auto exitCode = finished + ? ffmpeg.getExitCode() + : -1; if (finished && exitCode == 0 && extractedFile.existsAsFile()) { juce::Logger::writeToLog("importMediaFile: FFmpeg extracted audio to: " + extractedFile.getFullPathName()); @@ -3028,6 +11485,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, sampleRate, bitDepth, channels, normalizeArg, addTail, tailLength, includeMetronome, completion = std::make_shared<juce::WebBrowserComponent::NativeFunctionCompletion>(std::move(completion))]() { + const juce::ScopedLock processMutationLock(namModelMutationStateLock); bool success = audioEngine.renderProject( source, startTime, endTime, filePathArg, format, sampleRate, bitDepth, channels, normalizeArg, addTail, tailLength, @@ -3208,6 +11666,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, sampleRate, bitDepth, channels, normalizeArg, addTail, tailLength, ditherType, includeMetronome, completion = std::make_shared<juce::WebBrowserComponent::NativeFunctionCompletion>(std::move(completion))]() { + const juce::ScopedLock processMutationLock(namModelMutationStateLock); bool success = audioEngine.renderProjectWithDither( source, startTime, endTime, filePathArg, format, sampleRate, bitDepth, channels, normalizeArg, addTail, tailLength, ditherType, @@ -3649,7 +12108,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::File outFile(outputPath); outFile.deleteFile(); - std::unique_ptr<juce::FileOutputStream> stream(outFile.createOutputStream()); + std::unique_ptr<juce::OutputStream> stream(outFile.createOutputStream()); if (!stream) { juce::MessageManager::callAsync([completion]() { (*completion)(false); }); @@ -3660,16 +12119,18 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, int outSampleRate = targetSampleRate > 0 ? targetSampleRate : (int)reader->sampleRate; int outBitDepth = targetBitDepth > 0 ? targetBitDepth : (int)reader->bitsPerSample; - std::unique_ptr<juce::AudioFormatWriter> writer(outputFormat->createWriterFor( - stream.get(), outSampleRate, (unsigned int)outChannels, outBitDepth, {}, 0)); + auto writer = outputFormat->createWriterFor( + stream, + juce::AudioFormatWriterOptions() + .withSampleRate(outSampleRate) + .withNumChannels(outChannels) + .withBitsPerSample(outBitDepth)); if (!writer) { juce::MessageManager::callAsync([completion]() { (*completion)(false); }); return; } - stream.release(); // Writer takes ownership - // Read and write in blocks const int blockSize = 8192; juce::AudioBuffer<float> buffer(outChannels, blockSize); @@ -3716,11 +12177,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, inputFile.getFileNameWithoutExtension() + "_ts_" + timestamp + inputFile.getFileExtension() ); - // Find FFmpeg - auto exeDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory(); - juce::File ffmpeg = exeDir.getChildFile("ffmpeg.exe"); - if (!ffmpeg.existsAsFile()) ffmpeg = exeDir.getChildFile("tools").getChildFile("ffmpeg.exe"); - if (!ffmpeg.existsAsFile()) ffmpeg = exeDir.getParentDirectory().getChildFile("tools").getChildFile("ffmpeg.exe"); + const auto ffmpeg = + OpenStudioFFmpeg::findExecutable(); if (!ffmpeg.existsAsFile()) { completion(juce::String()); return; @@ -3820,11 +12278,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, // Convert semitones to frequency ratio: ratio = 2^(semitones/12) double ratio = std::pow(2.0, semitones / 12.0); - // Find FFmpeg - auto exeDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory(); - juce::File ffmpeg = exeDir.getChildFile("ffmpeg.exe"); - if (!ffmpeg.existsAsFile()) ffmpeg = exeDir.getChildFile("tools").getChildFile("ffmpeg.exe"); - if (!ffmpeg.existsAsFile()) ffmpeg = exeDir.getParentDirectory().getChildFile("tools").getChildFile("ffmpeg.exe"); + const auto ffmpeg = + OpenStudioFFmpeg::findExecutable(); if (!ffmpeg.existsAsFile()) { completion(juce::String()); return; @@ -4105,6 +12560,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::String trackId = args[0].toString(); std::thread([this, trackId, completion = std::make_shared<juce::WebBrowserComponent::NativeFunctionCompletion>(std::move(completion))]() { + const juce::ScopedLock processMutationLock(namModelMutationStateLock); auto result = audioEngine.freezeTrack(trackId); juce::MessageManager::callAsync([completion, result]() { (*completion)(result); @@ -4129,6 +12585,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, // Args: [trackId, effectName, isInputFX?] if (args.size() >= 2 && args[0].isString() && args[1].isString()) { bool isInputFX = args.size() >= 3 && (bool)args[2]; + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool ok = audioEngine.addTrackBuiltInFX(args[0].toString(), args[1].toString(), isInputFX); completion(juce::var(ok)); } else { @@ -4138,6 +12596,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, .withNativeFunction ("addMasterBuiltInFX", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { // Args: [effectName] if (args.size() >= 1 && args[0].isString()) { + const juce::ScopedLock processMutationLock(namModelMutationStateLock); + invalidateNAMRackTopology(); bool ok = audioEngine.addMasterBuiltInFX(args[0].toString()); completion(juce::var(ok)); } else { @@ -4564,6 +13024,15 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, closeMidiEditorWindow(sessionId, "close"); }); } + else if (windowRole == WindowRole::pluginEditor && windowCallbacks.closePluginEditorWindow) + { + auto closePluginEditorWindow = windowCallbacks.closePluginEditorWindow; + const auto sessionId = windowInstanceId.isNotEmpty() ? windowInstanceId : juce::String("default-plugin-editor"); + juce::MessageManager::callAsync([closePluginEditorWindow, sessionId]() + { + closePluginEditorWindow(sessionId, "close"); + }); + } else if (windowCallbacks.closeMixerWindow) { auto closeMixerWindow = windowCallbacks.closeMixerWindow; @@ -4713,6 +13182,36 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, else completion(juce::var()); }) + .withNativeFunction ("openBuiltInPluginEditorWindow", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::String sessionId = windowInstanceId; + juce::var bounds; + if (args.size() > 0 && args[0].isString()) + sessionId = args[0].toString(); + if (args.size() > 1) + bounds = args[1]; + if (sessionId.isEmpty()) + sessionId = "default-plugin-editor"; + + const bool opened = windowCallbacks.openPluginEditorWindow ? windowCallbacks.openPluginEditorWindow(sessionId, bounds) : false; + completion(juce::var(opened)); + }) + .withNativeFunction ("closeBuiltInPluginEditorWindow", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + juce::String sessionId = args.size() > 0 && args[0].isString() ? args[0].toString() : windowInstanceId; + juce::String reason = args.size() > 1 && args[1].isString() ? args[1].toString() : "close"; + if (sessionId.isEmpty()) + sessionId = "default-plugin-editor"; + + const bool canClose = static_cast<bool>(windowCallbacks.closePluginEditorWindow); + completion(juce::var(canClose)); + if (canClose) + { + auto callback = windowCallbacks.closePluginEditorWindow; + juce::MessageManager::callAsync([callback, sessionId, reason]() + { + callback(sessionId, reason); + }); + } + }) .withNativeFunction ("publishAppCommand", [] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { const juce::var payload = args.size() > 0 ? args[0] : juce::var(); MainComponent::broadcastEventToRole(MainComponent::WindowRole::main, "appCommand", payload); @@ -4813,6 +13312,11 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(false); } }) + .withNativeFunction ("getTrackDCOffset", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + completion(args.size() >= 1 + ? audioEngine.getTrackDCOffset(args[0].toString()) + : false); + }) // Clip Gain Envelope (Phase 18.10) .withNativeFunction ("setClipGainEnvelope", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 3) { @@ -4825,7 +13329,18 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, // MIDI Learn (Phase 19.7) .withNativeFunction ("startMIDILearnForPlugin", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 3) { - audioEngine.startMIDILearnForPlugin(args[0].toString(), static_cast<int>(args[1]), static_cast<int>(args[2])); + const bool isInputFX = args.size() > 3 && static_cast<bool>(args[3]); + audioEngine.startMIDILearnForPlugin(args[0].toString(), static_cast<int>(args[1]), static_cast<int>(args[2]), isInputFX); + completion(true); + } else { + completion(false); + } + }) + .withNativeFunction ("startBuiltInMIDILearn", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.size() >= 4) { + audioEngine.startMIDILearnForBuiltIn( + args[0].toString(), args[1].toString(), + static_cast<int>(args[2]), args[3].toString()); completion(true); } else { completion(false); @@ -4848,6 +13363,9 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, juce::ignoreUnused(args); completion(audioEngine.getMIDILearnMappings()); }) + .withNativeFunction ("setMIDILearnMappings", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + completion(args.size() > 0 && audioEngine.setMIDILearnMappings(args[0])); + }) // MIDI Import/Export (Phase 19.9) .withNativeFunction ("importMIDIFile", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 1) { @@ -4912,10 +13430,10 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, auto suggestedName = juce::File::createLegalFileName( juce::File(args[0].toString()).getFileNameWithoutExtension()); if (suggestedName.isEmpty()) - suggestedName = "Studio13 MIDI Clip"; + suggestedName = "OpenStudio MIDI Clip"; auto dragDir = juce::File::getSpecialLocation(juce::File::tempDirectory) - .getChildFile("Studio13") + .getChildFile("OpenStudio") .getChildFile("MIDI Drag Exports"); dragDir.createDirectory(); @@ -4953,8 +13471,68 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) .withNativeFunction ("loadPluginPreset", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 4) { - bool ok = audioEngine.loadPluginPreset(args[0].toString(), static_cast<int>(args[1]), (bool)args[2], args[3].toString()); - completion(ok); + const auto trackId = args[0].toString(); + const auto fxIndex = static_cast<int>(args[1]); + const auto isInputFX = static_cast<bool>(args[2]); + const auto presetPath = args[3].toString(); + if (! audioEngine.isNAMRackPlugin(trackId, fxIndex, isInputFX)) + { + completion(audioEngine.loadPluginPreset( + trackId, fxIndex, isInputFX, presetPath)); + return; + } + + const auto chainType = isInputFX + ? juce::String("input") : juce::String("track"); + std::vector<std::pair<juce::String, juce::uint64>> namMutationRequests; + const auto topologyGeneration = beginNAMModelMutationRequests( + trackId, chainType, fxIndex, + { "pedal", "amp", "cab" }, namMutationRequests); + juce::Component::SafePointer<MainComponent> safeThis(this); + builtInStateMutationPool.addJob([ + safeThis, trackId, chainType, fxIndex, isInputFX, + presetPath, namMutationRequests, topologyGeneration, + completion]() mutable { + if (safeThis == nullptr) + return; + + bool stillCurrent = isNAMRackTopologyCurrent(topologyGeneration); + for (const auto& request : namMutationRequests) + { + stillCurrent = stillCurrent + && safeThis->isNAMModelMutationRequestCurrent( + trackId, chainType, fxIndex, + request.first, request.second); + } + if (! stillCurrent) + { + juce::MessageManager::callAsync([safeThis, completion]() mutable { + if (safeThis != nullptr) + completion(false); + }); + return; + } + + const auto publicationLeaseFactory = [ + safeThis, trackId, chainType, fxIndex, + namMutationRequests, topologyGeneration]() + { + return safeThis != nullptr + ? safeThis->acquireNAMModelMutationPublicationLease( + trackId, chainType, fxIndex, + namMutationRequests, topologyGeneration) + : std::shared_ptr<void>(); + }; + const bool applied = safeThis->audioEngine.loadPluginPreset( + trackId, fxIndex, isInputFX, presetPath, + publicationLeaseFactory); + const bool result = applied; + juce::MessageManager::callAsync([ + safeThis, completion, result]() mutable { + if (safeThis != nullptr) + completion(result); + }); + }); } else { completion(false); } @@ -4978,8 +13556,68 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, }) .withNativeFunction ("loadPluginABState", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 4) { - bool ok = audioEngine.loadPluginABState(args[0].toString(), static_cast<int>(args[1]), (bool)args[2], args[3].toString()); - completion(ok); + const auto trackId = args[0].toString(); + const auto fxIndex = static_cast<int>(args[1]); + const auto isInputFX = static_cast<bool>(args[2]); + const auto slot = args[3].toString(); + if (! audioEngine.isNAMRackPlugin(trackId, fxIndex, isInputFX)) + { + completion(audioEngine.loadPluginABState( + trackId, fxIndex, isInputFX, slot)); + return; + } + + const auto chainType = isInputFX + ? juce::String("input") : juce::String("track"); + std::vector<std::pair<juce::String, juce::uint64>> namMutationRequests; + const auto topologyGeneration = beginNAMModelMutationRequests( + trackId, chainType, fxIndex, + { "pedal", "amp", "cab" }, namMutationRequests); + juce::Component::SafePointer<MainComponent> safeThis(this); + builtInStateMutationPool.addJob([ + safeThis, trackId, chainType, fxIndex, isInputFX, + slot, namMutationRequests, topologyGeneration, + completion]() mutable { + if (safeThis == nullptr) + return; + + bool stillCurrent = isNAMRackTopologyCurrent(topologyGeneration); + for (const auto& request : namMutationRequests) + { + stillCurrent = stillCurrent + && safeThis->isNAMModelMutationRequestCurrent( + trackId, chainType, fxIndex, + request.first, request.second); + } + if (! stillCurrent) + { + juce::MessageManager::callAsync([safeThis, completion]() mutable { + if (safeThis != nullptr) + completion(false); + }); + return; + } + + const auto publicationLeaseFactory = [ + safeThis, trackId, chainType, fxIndex, + namMutationRequests, topologyGeneration]() + { + return safeThis != nullptr + ? safeThis->acquireNAMModelMutationPublicationLease( + trackId, chainType, fxIndex, + namMutationRequests, topologyGeneration) + : std::shared_ptr<void>(); + }; + const bool applied = safeThis->audioEngine.loadPluginABState( + trackId, fxIndex, isInputFX, slot, + publicationLeaseFactory); + const bool result = applied; + juce::MessageManager::callAsync([ + safeThis, completion, result]() mutable { + if (safeThis != nullptr) + completion(result); + }); + }); } else { completion(false); } @@ -5041,6 +13679,11 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, completion(false); } }) + .withNativeFunction ("getChannelStripEQEnabled", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + completion(args.size() >= 1 + ? audioEngine.getChannelStripEQEnabled(args[0].toString()) + : false); + }) .withNativeFunction ("setChannelStripEQParam", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { if (args.size() >= 3) { audioEngine.setChannelStripEQParam( @@ -5114,7 +13757,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, obj->setProperty ("started", true); completion (juce::var (obj.release())); - pitchAnalysisPool.addJob ([this, trackId, clipId, analysisGeneration]() { + juce::Component::SafePointer<MainComponent> safeThis(this); + pitchAnalysisPool.addJob ([this, safeThis, trackId, clipId, analysisGeneration]() { juce::Logger::writeToLog ("PitchAnalysis: Starting for track=" + trackId + " clip=" + clipId); auto shouldCancelAnalysis = [this, analysisGeneration]() { @@ -5147,7 +13791,10 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, lastPitchAnalysisResult = result; } - juce::MessageManager::callAsync ([this, clipId, noteCount, hasResult, cancelled]() { + juce::MessageManager::callAsync ([safeThis, clipId, noteCount, hasResult, cancelled]() { + if (safeThis == nullptr || safeThis->secondaryWindowClosing) + return; + auto notification = std::make_unique<juce::DynamicObject>(); notification->setProperty ("clipId", clipId); notification->setProperty ("noteCount", noteCount); @@ -5155,7 +13802,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, notification->setProperty ("cancelled", cancelled); juce::Logger::writeToLog ("PitchAnalysis: Emitting lightweight event (noteCount=" + juce::String(noteCount) + ")"); - webView.emitEventIfBrowserIsVisible ("pitchAnalysisComplete", + safeThis->webView.emitEventIfBrowserIsVisible ("pitchAnalysisComplete", juce::var (notification.release())); }); }); @@ -5194,7 +13841,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, obj->setProperty ("started", true); completion (juce::var (obj.release())); - pitchAnalysisPool.addJob ([this, filePath, offset, duration, clipId, analysisGeneration]() { + juce::Component::SafePointer<MainComponent> safeThis(this); + pitchAnalysisPool.addJob ([this, safeThis, filePath, offset, duration, clipId, analysisGeneration]() { juce::Logger::writeToLog ("PitchAnalysis: Starting for " + filePath + " offset=" + juce::String(offset) + " dur=" + juce::String(duration)); auto shouldCancelAnalysis = [this, analysisGeneration]() @@ -5229,7 +13877,10 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, lastPitchAnalysisResult = result; } - juce::MessageManager::callAsync ([this, clipId, noteCount, hasResult, cancelled]() { + juce::MessageManager::callAsync ([safeThis, clipId, noteCount, hasResult, cancelled]() { + if (safeThis == nullptr || safeThis->secondaryWindowClosing) + return; + // Send lightweight notification with metadata only auto notification = std::make_unique<juce::DynamicObject>(); notification->setProperty ("clipId", clipId); @@ -5238,7 +13889,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, notification->setProperty ("cancelled", cancelled); juce::Logger::writeToLog ("PitchAnalysis: Emitting lightweight event (noteCount=" + juce::String(noteCount) + ")"); - webView.emitEventIfBrowserIsVisible ("pitchAnalysisComplete", + safeThis->webView.emitEventIfBrowserIsVisible ("pitchAnalysisComplete", juce::var (notification.release())); }); }); @@ -5355,7 +14006,8 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, pitchNoteHqPriorityGeneration.store (renderGeneration); const auto queuedAtMs = juce::Time::currentTimeMillis(); completion(true); - targetPool->addJob ([this, trackId, clipId, notes, frames, requestId, requestGroupId, globalFormantSemitones, windowStartSec, windowEndSec, renderMode, renderGeneration, isPreviewSegment, isNoteRender, queuedAtMs]() mutable { + juce::Component::SafePointer<MainComponent> safeThis(this); + targetPool->addJob ([this, safeThis, trackId, clipId, notes, frames, requestId, requestGroupId, globalFormantSemitones, windowStartSec, windowEndSec, renderMode, renderGeneration, isPreviewSegment, isNoteRender, queuedAtMs]() mutable { auto shouldCancel = [this, renderGeneration, requestGroupId, isPreviewSegment, isNoteRender]() { const juce::ScopedLock sl (pitchCorrectionJobLock); if (isPreviewSegment) @@ -5364,6 +14016,19 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, return noteRenderGeneration.load() != renderGeneration || activeNoteRenderRequestGroup != requestGroupId; return fullClipRenderGeneration.load() != renderGeneration || activeFullClipRequestGroup != requestGroupId; }; + auto guardedCommit = [this, renderGeneration, requestGroupId, isPreviewSegment, isNoteRender] + (const std::function<void()>& commit) { + const juce::ScopedLock sl (pitchCorrectionJobLock); + const bool isCurrent = isPreviewSegment + ? previewRenderGeneration.load() == renderGeneration && activePreviewRequestGroup == requestGroupId + : (isNoteRender + ? noteRenderGeneration.load() == renderGeneration && activeNoteRenderRequestGroup == requestGroupId + : fullClipRenderGeneration.load() == renderGeneration && activeFullClipRequestGroup == requestGroupId); + if (! isCurrent) + return false; + commit(); + return true; + }; logPitchEditorFormant ("job starting clip=" + clipId + " requestId=" + requestId + " requestGroupId=" + requestGroupId @@ -5373,7 +14038,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, const double jobStartDelayMs = static_cast<double> (juce::Time::currentTimeMillis() - queuedAtMs); auto result = shouldCancel() ? juce::var() - : audioEngine.applyPitchCorrection(trackId, clipId, notes, frames, globalFormantSemitones, windowStartSec, windowEndSec, renderMode, shouldCancel, jobStartDelayMs, isPreviewSegment ? renderGeneration : 0); + : audioEngine.applyPitchCorrection(trackId, clipId, notes, frames, globalFormantSemitones, windowStartSec, windowEndSec, renderMode, shouldCancel, jobStartDelayMs, isPreviewSegment ? renderGeneration : 0, guardedCommit); if (isNoteRender && pitchNoteHqPriorityGeneration.load() == renderGeneration) pitchNoteHqPriorityActive.store (false); bool success = result.isObject() @@ -5792,7 +14457,10 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, + " previewCoverageEnd=" + juce::String(previewCoverageEndSec, 3) + " restored=" + juce::String(restored ? "true" : "false") + " outputFile=" + outputFile); - juce::MessageManager::callAsync ([this, clipId, success, outputFile, requestId, restored, renderMode, cancelled, swapDeferred, previewCoverageStartSec, previewCoverageEndSec, candidateCoverageStartSec, candidateCoverageEndSec, requestedRendererBranch, actualRendererBranch, pitchOnlyRecoveryPath, pitchOnlyNeutralFormantUsed, processingMode, formantCurveUsed, explicitFormantRequested, pitchOnlyFormantSuppressed, usedFallback, fallbackReason, hardFailReason, pitchRenderStrategy, phraseHqRenderUsed, phraseHqExpandedToFullClip, phraseHqStartSec, phraseHqEndSec, pitchRenderProductPath, pitchRenderBackendId, pitchRenderBackendVersion, pitchRenderBackendFailureCode, pitchRenderBackendCapabilities, pitchRenderBackendDiagnostics, pitchRenderCommitPolicy, pitchRenderDryProtectedSamples, pitchRenderContextDurationSec, pitchRenderCommitDurationSec, pitchRenderJobStartDelayMs, pitchRenderDirection, downshiftFormantGuardUsed, downshiftFormantGuardAlpha, noteHqEffectiveStartSec, noteHqEffectiveEndSec, noteHqContextStartSec, noteHqContextEndSec, noteHqAudibleCommitStartSec, noteHqAudibleCommitEndSec, noteHqPreBodyDryProtectedSamples, noteHqEntryInsideBodyFadeMs, noteHqExitLeadInMs, noteHqEntryBridgeStartSec, noteHqEntryBridgeEndSec, noteHqEntryBridgeWetLagMs, noteHqEntryBridgeEnvelopeGainDb, noteHqEntryBridgeUsed, noteHqEntryTransientDryPreservedMs, pitchOnlyEntrySimpleHandoffUsed, pitchOnlyEntrySafeHandoffUsed, pitchOnlyEntryDryHoldMs, pitchOnlyEntrySafeBridgeMs, pitchOnlyEntryWetAlignmentMs, pitchOnlyEntryWetGainDb, pitchOnlyEntryWetVsDryRmsDb, pitchOnlyEntryEqualPowerBlendUsed, pitchOnlyEntryRmsContinuityUsed, pitchOnlyEntryRmsContinuityGainDb, pitchOnlyEntryRmsContinuityMs, pitchOnlyEntryPhaseSafeUsed, pitchOnlyEntryWetAlignmentAccepted, pitchOnlyEntryFirstCycleCorrelation, pitchOnlyEntryZeroCrossOffsetMs, pitchOnlyEntryBridgeGainRampDb, pitchOnlyDownshiftCoreEnvelopePassUsed, pitchOnlyDownshiftCoreRmsTrimDb, pitchOnlyDownshiftCoreEnvelopeMaxDb, pitchOnlyDownshiftCoreEnvelopeFrames, pitchOnlyEntryWetLagMs, pitchOnlyEntryBridgeDurationMs, pitchOnlyExitDryRestoreUsed, pitchOnlyExitDryRestoreStartSec, pitchOnlyExitDryRestoreEndSec, noteHqEditIslandCount, noteHqEditedNoteCount, noteHqEntryPitchHandoffUsed, noteHqEntryPitchHandoffStartSec, noteHqEntryPitchHandoffEndSec, noteHqEntryPitchHandoffPreMs, noteHqEntryPitchHandoffBodyMs, noteHqEntryPitchSlopeJumpStPerSec, noteHqEntryPitchAccelerationLimited, outputDurationSec, postApplyRouteStatus, appFinalCapture, appFinalBakedCapture, appFinalParityReport, appFinalRouteReportPath, appFinalBakedContextPath, appFinalPlaybackContextPath, appFinalParityReportPath, bridgeUsed, bridgeFallbackUsed, bridgeStartSec, bridgeLengthMs, bridgeAlignmentLagSamples, bridgeCorrelationScore, bridgeGainDeltaDb, bodyReplacementUsed, bodyReplacementFallbackUsed, entryLockStartSec, entryLockLengthMs, exitLockStartSec, renderedBodyStartSec, renderedBodyEndSec, islandNativeUsed, islandNativeFallbackUsed, islandRenderStartSec, islandRenderEndSec, transientMaskPeak, voicedCoreMaskPeak, hpssUsed, hpssFallbackUsed, harmonicMaskPeak, aperiodicMaskPeak, spectralEnvelopeCorrectionUsed, pitchOnlyCoreTimbreCorrectionUsed, pitchOnlyCoreEnvelopeMix, pitchOnlyCoreRmsTrimDb, pitchOnlyCoreEnvelopeLifter, pitchOnlyEntryTimbreCorrectionUsed, pitchOnlyEntryRmsTrimDb, pitchOnlyEntryTiltDb, pitchOnlyEntryHandoffUsed, pitchOnlyExitHandoffUsed, vocalSourceFilterUsed, vocalSourceFilterVoicedCoverage, vocalSourceFilterResidualMix, vocalSourceFilterFallbackUsed, vocalSourceFilterFallbackReason, vocalSourceFilterEntryDryMs, vocalSourceFilterExitDryMs, wsolaUsed, wsolaFallbackUsed, wsolaEntryLagSamples, wsolaExitLagSamples, wsolaCorrelationScore, phaseLockUsed, phaseLockFallbackUsed, phaseAlignedEntry, phaseAlignedExit, phasePeakCount, transitionHqUsed, transitionHqFallbackUsed, transitionStartSec, transitionEndSec, transitionTransientPeak, transitionVoicedCorePeak, transitionResidualPeak, transitionEnvelopeCorrectionUsed, engineV2Used, engineV2FallbackUsed, engineV2TransitionCount, engineV2TransitionStartSec, engineV2TransitionEndSec, engineV2HarmonicSupportPeak, engineV2ResidualSupportPeak, engineV2EnvelopeSupportPeak, transientBypassUsed, residualCarryUsed, cepstralCutoffUsed, engineV2FftSize, engineV2HopSize, immediateLeftNeighborUsed, immediateRightNeighborUsed, leftNeighborSamplesRendered, rightNeighborSamplesRendered, leftNeighborSmoothMs, rightNeighborSmoothMs, nonImmediateNeighborTouched, entryAlignmentOffsetMs, exitAlignmentOffsetMs, firstVoicedCyclesEntryUsed, firstVoicedCyclesExitUsed, v3TransitionPairUsed, v3ContinuousRenderUsed, v3EntryAnchorMs, v3ExitAnchorMs, v3FirstCyclesEntryCount, v3FirstCyclesExitCount, v3ShellDurationMs, v3BodyDurationMs, v3ResidualMix, v3FormantMode, v3NeighborLeftOverlapMs, v3NeighborRightOverlapMs]() { + juce::MessageManager::callAsync ([safeThis, clipId, success, outputFile, requestId, restored, renderMode, cancelled, swapDeferred, previewCoverageStartSec, previewCoverageEndSec, candidateCoverageStartSec, candidateCoverageEndSec, requestedRendererBranch, actualRendererBranch, pitchOnlyRecoveryPath, pitchOnlyNeutralFormantUsed, processingMode, formantCurveUsed, explicitFormantRequested, pitchOnlyFormantSuppressed, usedFallback, fallbackReason, hardFailReason, pitchRenderStrategy, phraseHqRenderUsed, phraseHqExpandedToFullClip, phraseHqStartSec, phraseHqEndSec, pitchRenderProductPath, pitchRenderBackendId, pitchRenderBackendVersion, pitchRenderBackendFailureCode, pitchRenderBackendCapabilities, pitchRenderBackendDiagnostics, pitchRenderCommitPolicy, pitchRenderDryProtectedSamples, pitchRenderContextDurationSec, pitchRenderCommitDurationSec, pitchRenderJobStartDelayMs, pitchRenderDirection, downshiftFormantGuardUsed, downshiftFormantGuardAlpha, noteHqEffectiveStartSec, noteHqEffectiveEndSec, noteHqContextStartSec, noteHqContextEndSec, noteHqAudibleCommitStartSec, noteHqAudibleCommitEndSec, noteHqPreBodyDryProtectedSamples, noteHqEntryInsideBodyFadeMs, noteHqExitLeadInMs, noteHqEntryBridgeStartSec, noteHqEntryBridgeEndSec, noteHqEntryBridgeWetLagMs, noteHqEntryBridgeEnvelopeGainDb, noteHqEntryBridgeUsed, noteHqEntryTransientDryPreservedMs, pitchOnlyEntrySimpleHandoffUsed, pitchOnlyEntrySafeHandoffUsed, pitchOnlyEntryDryHoldMs, pitchOnlyEntrySafeBridgeMs, pitchOnlyEntryWetAlignmentMs, pitchOnlyEntryWetGainDb, pitchOnlyEntryWetVsDryRmsDb, pitchOnlyEntryEqualPowerBlendUsed, pitchOnlyEntryRmsContinuityUsed, pitchOnlyEntryRmsContinuityGainDb, pitchOnlyEntryRmsContinuityMs, pitchOnlyEntryPhaseSafeUsed, pitchOnlyEntryWetAlignmentAccepted, pitchOnlyEntryFirstCycleCorrelation, pitchOnlyEntryZeroCrossOffsetMs, pitchOnlyEntryBridgeGainRampDb, pitchOnlyDownshiftCoreEnvelopePassUsed, pitchOnlyDownshiftCoreRmsTrimDb, pitchOnlyDownshiftCoreEnvelopeMaxDb, pitchOnlyDownshiftCoreEnvelopeFrames, pitchOnlyEntryWetLagMs, pitchOnlyEntryBridgeDurationMs, pitchOnlyExitDryRestoreUsed, pitchOnlyExitDryRestoreStartSec, pitchOnlyExitDryRestoreEndSec, noteHqEditIslandCount, noteHqEditedNoteCount, noteHqEntryPitchHandoffUsed, noteHqEntryPitchHandoffStartSec, noteHqEntryPitchHandoffEndSec, noteHqEntryPitchHandoffPreMs, noteHqEntryPitchHandoffBodyMs, noteHqEntryPitchSlopeJumpStPerSec, noteHqEntryPitchAccelerationLimited, outputDurationSec, postApplyRouteStatus, appFinalCapture, appFinalBakedCapture, appFinalParityReport, appFinalRouteReportPath, appFinalBakedContextPath, appFinalPlaybackContextPath, appFinalParityReportPath, bridgeUsed, bridgeFallbackUsed, bridgeStartSec, bridgeLengthMs, bridgeAlignmentLagSamples, bridgeCorrelationScore, bridgeGainDeltaDb, bodyReplacementUsed, bodyReplacementFallbackUsed, entryLockStartSec, entryLockLengthMs, exitLockStartSec, renderedBodyStartSec, renderedBodyEndSec, islandNativeUsed, islandNativeFallbackUsed, islandRenderStartSec, islandRenderEndSec, transientMaskPeak, voicedCoreMaskPeak, hpssUsed, hpssFallbackUsed, harmonicMaskPeak, aperiodicMaskPeak, spectralEnvelopeCorrectionUsed, pitchOnlyCoreTimbreCorrectionUsed, pitchOnlyCoreEnvelopeMix, pitchOnlyCoreRmsTrimDb, pitchOnlyCoreEnvelopeLifter, pitchOnlyEntryTimbreCorrectionUsed, pitchOnlyEntryRmsTrimDb, pitchOnlyEntryTiltDb, pitchOnlyEntryHandoffUsed, pitchOnlyExitHandoffUsed, vocalSourceFilterUsed, vocalSourceFilterVoicedCoverage, vocalSourceFilterResidualMix, vocalSourceFilterFallbackUsed, vocalSourceFilterFallbackReason, vocalSourceFilterEntryDryMs, vocalSourceFilterExitDryMs, wsolaUsed, wsolaFallbackUsed, wsolaEntryLagSamples, wsolaExitLagSamples, wsolaCorrelationScore, phaseLockUsed, phaseLockFallbackUsed, phaseAlignedEntry, phaseAlignedExit, phasePeakCount, transitionHqUsed, transitionHqFallbackUsed, transitionStartSec, transitionEndSec, transitionTransientPeak, transitionVoicedCorePeak, transitionResidualPeak, transitionEnvelopeCorrectionUsed, engineV2Used, engineV2FallbackUsed, engineV2TransitionCount, engineV2TransitionStartSec, engineV2TransitionEndSec, engineV2HarmonicSupportPeak, engineV2ResidualSupportPeak, engineV2EnvelopeSupportPeak, transientBypassUsed, residualCarryUsed, cepstralCutoffUsed, engineV2FftSize, engineV2HopSize, immediateLeftNeighborUsed, immediateRightNeighborUsed, leftNeighborSamplesRendered, rightNeighborSamplesRendered, leftNeighborSmoothMs, rightNeighborSmoothMs, nonImmediateNeighborTouched, entryAlignmentOffsetMs, exitAlignmentOffsetMs, firstVoicedCyclesEntryUsed, firstVoicedCyclesExitUsed, v3TransitionPairUsed, v3ContinuousRenderUsed, v3EntryAnchorMs, v3ExitAnchorMs, v3FirstCyclesEntryCount, v3FirstCyclesExitCount, v3ShellDurationMs, v3BodyDurationMs, v3ResidualMix, v3FormantMode, v3NeighborLeftOverlapMs, v3NeighborRightOverlapMs]() { + if (safeThis == nullptr || safeThis->secondaryWindowClosing) + return; + logPitchEditorFormant ("emitting pitchCorrectionComplete clip=" + clipId + " requestId=" + requestId + " renderMode=" + renderMode @@ -6008,7 +14676,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, obj->setProperty ("v3FormantMode", v3FormantMode); obj->setProperty ("v3NeighborLeftOverlapMs", v3NeighborLeftOverlapMs); obj->setProperty ("v3NeighborRightOverlapMs", v3NeighborRightOverlapMs); - webView.emitEventIfBrowserIsVisible ("pitchCorrectionComplete", + safeThis->webView.emitEventIfBrowserIsVisible ("pitchCorrectionComplete", juce::var (obj.release())); }); }); @@ -6173,6 +14841,42 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, audioEngine.getPlaybackEngine().clearAllPitchPreviewRoutes (args.size() >= 1 ? args[0].toString() : juce::String()); completion (true); }) + .withNativeFunction ("cancelPitchCorrectionRequests", [this] (const juce::Array<juce::var>& args, juce::WebBrowserComponent::NativeFunctionCompletion completion) { + if (args.isEmpty()) + { + completion (false); + return; + } + + const auto clipId = args[0].toString(); + const juce::File authoritativeFile (args.size() >= 2 ? args[1].toString() : juce::String()); + { + // The same lock is held by the render commit callback. Therefore + // cancellation either wins before a stale swap, or restores the + // authoritative frontend source after a swap that just completed. + const juce::ScopedLock sl (pitchCorrectionJobLock); + ++previewRenderGeneration; + ++noteRenderGeneration; + ++fullClipRenderGeneration; + activePreviewRequestGroup = {}; + activeNoteRenderRequestGroup = {}; + activeFullClipRequestGroup = {}; + + auto& playbackEngine = audioEngine.getPlaybackEngine(); + playbackEngine.clearAllPitchPreviewRoutes (clipId); + playbackEngine.cancelDeferredClipAudioFile (clipId); + if (authoritativeFile.existsAsFile()) + playbackEngine.replaceClipAudioFile (clipId, authoritativeFile); + } + + pitchNoteHqPriorityActive.store (false); + previewSegmentPool.removeAllJobs (false, 0); + noteRenderPool.removeAllJobs (false, 0); + fullClipHQPool.removeAllJobs (false, 0); + logPitchEditorFormant ("cancelled pitch correction requests clip=" + clipId + + " authoritativeFile=" + authoritativeFile.getFullPathName()); + completion (true); + }) .withNativeFunction ("clearPitchPreviewRoutesForCorrectedSources", [this] (const juce::Array<juce::var>&, juce::WebBrowserComponent::NativeFunctionCompletion completion) { completion (audioEngine.getPlaybackEngine().clearPitchPreviewRoutesForCorrectedSources()); }) @@ -6281,9 +14985,7 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, const auto packagedFrontend = getPackagedFrontendEntryPoint(); const auto webViewUserDataDir = getWebView2UserDataFolder(); - auto checkOptions = juce::WebBrowserComponent::Options() - .withBackend(preferredBackend); - + const auto checkOptions = getEmbeddedBrowserBaseOptions(); const bool supported = juce::WebBrowserComponent::areOptionsSupported(checkOptions); const auto dependencyStatus = evaluateStartupDependencies(supported); @@ -6398,15 +15100,15 @@ MainComponent::MainComponent(AudioEngine& audioEngineIn, #if JUCE_DEBUG if (isLocalFrontendDevServerReachable()) { - const auto frontendUrl = appendFrontendStartupQuery("http://127.0.0.1:5173", windowRole, startupMode, windowInstanceId); - juce::Logger::writeToLog("Loading frontend from 127.0.0.1:5173"); + const auto frontendUrl = appendFrontendStartupQuery("http://127.0.0.1:5183", windowRole, startupMode, windowInstanceId); + juce::Logger::writeToLog("Loading frontend from 127.0.0.1:5183"); beginFrontendStartupWatchdog(frontendUrl); webView.goToURL(frontendUrl); loadedFrontend = true; } else { - juce::Logger::writeToLog("127.0.0.1:5173 is unreachable; falling back to the packaged frontend."); + juce::Logger::writeToLog("127.0.0.1:5183 is unreachable; falling back to the packaged frontend."); } #endif @@ -6764,7 +15466,32 @@ bool MainComponent::completePitchRegressionJob(const juce::var& result) MainComponent::~MainComponent() { + tone3000NativeCompletionsEnabled.store( + false, std::memory_order_release); + if (tone3000TaskCancellation != nullptr) + tone3000TaskCancellation->store( + true, std::memory_order_release); + // These jobs own every long-running NAM/TONE3000 completion. Drain them + // while the WebView provider, lifetime flag, and macOS Keychain loader are + // still alive; queued message-thread completions are additionally gated by + // SafePointer and the flag above. + tone3000BridgePool.removeAllJobs(true, -1); + + if (! isMainWindow()) + prepareForSecondaryWindowClose(); + stopTimer(); + ++pitchAnalysisGeneration; + ++previewRenderGeneration; + ++noteRenderGeneration; + ++fullClipRenderGeneration; + pitchAnalysisRunning.store(false); + pitchNoteHqPriorityActive.store(false); + builtInStateMutationPool.removeAllJobs(true, -1); + pitchAnalysisPool.removeAllJobs(true, 5000); + previewSegmentPool.removeAllJobs(true, 5000); + noteRenderPool.removeAllJobs(true, 5000); + fullClipHQPool.removeAllJobs(true, 5000); polyAnalysisBridgePool.removeAllJobs(true, 5000); mediaPreviewPool.removeAllJobs(true, 2000); #if JUCE_WINDOWS @@ -6774,6 +15501,102 @@ MainComponent::~MainComponent() activeInstances.removeFirstMatchingValue(this); } +void MainComponent::prepareForSecondaryWindowClose() +{ + if (isMainWindow() || secondaryWindowClosing) + return; + + secondaryWindowClosing = true; + tone3000NativeCompletionsEnabled.store( + false, std::memory_order_release); + if (tone3000TaskCancellation != nullptr) + tone3000TaskCancellation->store( + true, std::memory_order_release); + startupWatchdogActive = false; + frontendStartupDetail = "Secondary window is closing."; + stopTimer(); + + ++pitchAnalysisGeneration; + ++previewRenderGeneration; + ++noteRenderGeneration; + ++fullClipRenderGeneration; + pitchAnalysisRunning.store(false); + pitchNoteHqPriorityActive.store(false); + + hideStartupOverlay(); + fallbackMessage.setVisible(false); + hideStartupFallbackActions(); + + webView.setVisible(false); + webView.stop(); + + juce::Logger::writeToLog("Secondary MainComponent shutdown prepared: role=" + + getWindowRoleQueryValue(windowRole) + + (windowInstanceId.isNotEmpty() ? " sessionId=" + windowInstanceId : juce::String())); +} + +void MainComponent::requestEmbeddedBrowserFocus() +{ + jassert(juce::MessageManager::getInstance()->isThisTheMessageThread()); + + if (secondaryWindowClosing + || frontendStartupState != FrontendStartupState::ready + || embeddedBrowserFocusRequestPending) + { + return; + } + + embeddedBrowserFocusRequestPending = true; + juce::Component::SafePointer<MainComponent> safeThis(this); + juce::MessageManager::callAsync([safeThis]() + { + if (safeThis == nullptr) + return; + + safeThis->embeddedBrowserFocusRequestPending = false; + + if (safeThis->secondaryWindowClosing + || safeThis->frontendStartupState != FrontendStartupState::ready + || ! safeThis->webView.isShowing() + || safeThis->isCurrentlyBlockedByAnotherModalComponent() + || safeThis->webView.isCurrentlyBlockedByAnotherModalComponent()) + { + return; + } + + if (auto* modalComponent = juce::Component::getCurrentlyModalComponent(); + modalComponent != nullptr && modalComponent->isVisible()) + { + return; + } + + auto* topLevelWindow = dynamic_cast<juce::TopLevelWindow*>( + safeThis->getTopLevelComponent()); + if (topLevelWindow == nullptr || ! topLevelWindow->isActiveWindow()) + return; + + safeThis->webView.setWantsKeyboardFocus(true); + safeThis->webView.grabKeyboardFocus(); + }); +} + +bool MainComponent::hasFrontendStartupReachedTerminalState() const +{ + return frontendStartupState == FrontendStartupState::ready + || frontendStartupState == FrontendStartupState::failed + || frontendStartupState == FrontendStartupState::timedOut; +} + +bool MainComponent::hasFrontendStartupSucceeded() const +{ + return frontendStartupState == FrontendStartupState::ready; +} + +juce::String MainComponent::getFrontendStartupStateDescription() const +{ + return describeFrontendStartupState(frontendStartupState); +} + #if JUCE_WINDOWS void MainComponent::emitExternalMediaDropTargetEvent(const juce::String& eventId, const juce::var& payload) { @@ -6839,7 +15662,25 @@ void MainComponent::requestFrontendAppClose() { if (! isMainWindow()) { - if (windowCallbacks.closeMixerWindow) + if (windowRole == WindowRole::midiEditor && windowCallbacks.closeMidiEditorWindow) + { + auto closeMidiEditorWindow = windowCallbacks.closeMidiEditorWindow; + const auto sessionId = windowInstanceId.isNotEmpty() ? windowInstanceId : juce::String("default-midi-editor"); + juce::MessageManager::callAsync([closeMidiEditorWindow, sessionId]() + { + closeMidiEditorWindow(sessionId, "close"); + }); + } + else if (windowRole == WindowRole::pluginEditor && windowCallbacks.closePluginEditorWindow) + { + auto closePluginEditorWindow = windowCallbacks.closePluginEditorWindow; + const auto sessionId = windowInstanceId.isNotEmpty() ? windowInstanceId : juce::String("default-plugin-editor"); + juce::MessageManager::callAsync([closePluginEditorWindow, sessionId]() + { + closePluginEditorWindow(sessionId, "close"); + }); + } + else if (windowCallbacks.closeMixerWindow) { auto closeMixerWindow = windowCallbacks.closeMixerWindow; juce::MessageManager::callAsync([closeMixerWindow]() @@ -6890,10 +15731,24 @@ void MainComponent::broadcastEventToRole(WindowRole role, const juce::String& ev void MainComponent::emitFrontendEvent(const juce::String& eventId, const juce::var& payload) { + if (secondaryWindowClosing) + return; + + if (auto* messageManager = + juce::MessageManager::getInstanceWithoutCreating(); + messageManager != nullptr + && messageManager->isThisTheMessageThread()) + { + webView.emitEventIfBrowserIsVisible( + eventId, + payload); + return; + } + juce::Component::SafePointer<MainComponent> safeThis(this); juce::MessageManager::callAsync([safeThis, eventId, payload]() { - if (safeThis != nullptr) + if (safeThis != nullptr && ! safeThis->secondaryWindowClosing) safeThis->webView.emitEventIfBrowserIsVisible(eventId, payload); }); } @@ -6905,6 +15760,9 @@ bool MainComponent::isMainWindow() const bool MainComponent::loadPackagedFrontend() { + if (secondaryWindowClosing) + return false; + const auto packagedFrontend = getPackagedFrontendEntryPoint(); if (! packagedFrontend.existsAsFile()) return false; @@ -6927,8 +15785,8 @@ bool MainComponent::tryFallbackToPackagedFrontendAfterLocalTimeout() if (attemptedPackagedFrontendFallbackAfterLocalTimeout) return false; - if (! frontendStartupTargetUrl.startsWithIgnoreCase("http://localhost:5173") - && ! frontendStartupTargetUrl.startsWithIgnoreCase("http://127.0.0.1:5173")) + if (! frontendStartupTargetUrl.startsWithIgnoreCase("http://localhost:5183") + && ! frontendStartupTargetUrl.startsWithIgnoreCase("http://127.0.0.1:5183")) return false; attemptedPackagedFrontendFallbackAfterLocalTimeout = true; @@ -6938,7 +15796,7 @@ bool MainComponent::tryFallbackToPackagedFrontendAfterLocalTimeout() "not retrying with packaged frontend in Debug mode."); return false; #else - juce::Logger::writeToLog("Frontend startup timed out while using localhost:5173; " + juce::Logger::writeToLog("Frontend startup timed out while using localhost:5183; " "retrying with the packaged frontend."); return loadPackagedFrontend(); #endif @@ -6946,6 +15804,13 @@ bool MainComponent::tryFallbackToPackagedFrontendAfterLocalTimeout() void MainComponent::beginFrontendStartupWatchdog(const juce::String& targetUrl) { + if (secondaryWindowClosing) + { + juce::Logger::writeToLog("Frontend startup watchdog ignored for closing secondary window: role=" + + getWindowRoleQueryValue(windowRole)); + return; + } + frontendStartupTargetUrl = targetUrl; frontendStartupDetail.clear(); frontendStartupState = FrontendStartupState::navigationStarted; @@ -6974,6 +15839,13 @@ void MainComponent::hideStartupOverlay() void MainComponent::markFrontendStartupReady(const juce::String& detail) { + if (secondaryWindowClosing) + { + juce::Logger::writeToLog("Frontend startup state ignored after secondary close: boot-ready" + + (detail.isNotEmpty() ? " - " + detail : "")); + return; + } + if (frontendStartupState == FrontendStartupState::ready) return; @@ -6987,10 +15859,43 @@ void MainComponent::markFrontendStartupReady(const juce::String& detail) hideStartupOverlay(); webView.setVisible(true); juce::Logger::writeToLog("Frontend startup state: boot-ready" + (detail.isNotEmpty() ? " - " + detail : "")); + requestEmbeddedBrowserFocus(); + + if (! isMainWindow()) + { + auto* payload = new juce::DynamicObject(); + payload->setProperty("role", getWindowRoleQueryValue(windowRole)); + payload->setProperty("sessionId", windowInstanceId); + payload->setProperty("detail", detail); + payload->setProperty("startupState", describeFrontendStartupState(frontendStartupState)); + + MainComponent::broadcastEventToRole(WindowRole::main, "secondaryWindowReady", juce::var(payload)); + + auto* rolePayload = new juce::DynamicObject(); + rolePayload->setProperty("role", getWindowRoleQueryValue(windowRole)); + rolePayload->setProperty("sessionId", windowInstanceId); + rolePayload->setProperty("detail", detail); + + if (windowRole == WindowRole::mixer) + MainComponent::broadcastEventToRole(WindowRole::main, "mixerWindowReady", juce::var(rolePayload)); + else if (windowRole == WindowRole::midiEditor) + MainComponent::broadcastEventToRole(WindowRole::main, "midiEditorWindowReady", juce::var(rolePayload)); + else if (windowRole == WindowRole::pluginEditor) + MainComponent::broadcastEventToRole(WindowRole::main, "builtInPluginEditorWindowReady", juce::var(rolePayload)); + else + delete rolePayload; + } } void MainComponent::markFrontendStartupFailed(const juce::String& detail) { + if (secondaryWindowClosing) + { + juce::Logger::writeToLog("Frontend startup state ignored after secondary close: boot-failed" + + (detail.isNotEmpty() ? " - " + detail : "")); + return; + } + frontendStartupState = FrontendStartupState::failed; frontendStartupDetail = detail; startupWatchdogActive = false; @@ -7134,8 +16039,7 @@ void MainComponent::repairWindowsPrerequisites() juce::var MainComponent::buildStartupDiagnostics() const { const auto dependencyStatus = evaluateStartupDependencies( - juce::WebBrowserComponent::areOptionsSupported( - juce::WebBrowserComponent::Options().withBackend(getPreferredBrowserBackend()))); + juce::WebBrowserComponent::areOptionsSupported(getEmbeddedBrowserBaseOptions())); auto* diagnostics = new juce::DynamicObject(); const auto selfTestReport = buildStartupSelfTestReport(); diagnostics->setProperty("windowRole", getWindowRoleQueryValue(windowRole)); @@ -7171,11 +16075,11 @@ juce::Rectangle<int> MainComponent::getDesktopWorkAreaForCurrentWindow() const { const auto bounds = topLevel->getBounds(); if (auto* display = juce::Desktop::getInstance().getDisplays().getDisplayForRect(bounds)) - return display->userArea; + return display->userBounds.getSmallestIntegerContainer(); } if (auto* display = juce::Desktop::getInstance().getDisplays().getPrimaryDisplay()) - return display->userArea; + return display->userBounds.getSmallestIntegerContainer(); return getScreenBounds(); } @@ -7311,6 +16215,9 @@ void MainComponent::startDesktopWindowDrag() //============================================================================== void MainComponent::timerCallback() { + if (secondaryWindowClosing) + return; + if (startupWatchdogActive && frontendStartupState != FrontendStartupState::ready) { ++frontendStartupNavigationTicks; @@ -7340,31 +16247,51 @@ void MainComponent::timerCallback() if (isMainWindow()) { - const auto aiToolsStatus = audioEngine.getAiToolsStatus(); const auto nowMs = juce::Time::getMillisecondCounterHiRes(); - bool installInProgress = false; - juce::String digest; - - if (auto* obj = aiToolsStatus.getDynamicObject()) + constexpr double idleAiToolsPollIntervalMs = 2000.0; + const bool shouldPollAiTools = + lastAiToolsStatusPollMs <= 0.0 + || lastAiToolsInstallInProgress + || nowMs - lastAiToolsStatusPollMs + >= idleAiToolsPollIntervalMs; + if (shouldPollAiTools) { - installInProgress = static_cast<bool>(obj->getProperty("installInProgress")); - digest = obj->getProperty("state").toString() - + "|" + juce::String(static_cast<double>(obj->getProperty("progress"))) - + "|" + obj->getProperty("message").toString() - + "|" + obj->getProperty("error").toString() - + "|" + obj->getProperty("errorCode").toString() - + "|" + obj->getProperty("statusWarning").toString() - + "|" + obj->getProperty("statusWarningCode").toString() - + "|" + obj->getProperty("installSessionId").toString() - + "|" + juce::String(static_cast<double>(obj->getProperty("elapsedMs"))); - } + const auto aiToolsStatus = + audioEngine.getAiToolsStatus(); + lastAiToolsStatusPollMs = nowMs; + bool installInProgress = false; + juce::String digest; - if (digest != lastAiToolsStatusDigest - || (installInProgress && nowMs - lastAiToolsStatusEmitMs >= 500.0)) - { - emitFrontendEvent("aiToolsStatusUpdate", aiToolsStatus); - lastAiToolsStatusDigest = digest; - lastAiToolsStatusEmitMs = nowMs; + if (auto* obj = aiToolsStatus.getDynamicObject()) + { + installInProgress = + static_cast<bool>( + obj->getProperty( + "installInProgress")); + digest = obj->getProperty("state").toString() + + "|" + juce::String(static_cast<double>(obj->getProperty("progress"))) + + "|" + obj->getProperty("message").toString() + + "|" + obj->getProperty("error").toString() + + "|" + obj->getProperty("errorCode").toString() + + "|" + obj->getProperty("statusWarning").toString() + + "|" + obj->getProperty("statusWarningCode").toString() + + "|" + obj->getProperty("installSessionId").toString() + + "|" + juce::String(static_cast<double>(obj->getProperty("elapsedMs"))); + } + lastAiToolsInstallInProgress = + installInProgress; + + if (digest != lastAiToolsStatusDigest + || (installInProgress + && nowMs - lastAiToolsStatusEmitMs + >= 500.0)) + { + emitFrontendEvent( + "aiToolsStatusUpdate", + aiToolsStatus); + lastAiToolsStatusDigest = digest; + lastAiToolsStatusEmitMs = nowMs; + } } } @@ -7395,26 +16322,37 @@ void MainComponent::timerCallback() } */ - // ========== Event-Based Metering ========== - // Emit meter levels as events to frontend (every ~33ms at 30Hz) - juce::var meterData(new juce::DynamicObject()); - auto* obj = meterData.getDynamicObject(); - - // Get track meter levels - auto trackLevels = audioEngine.getMeterLevels(); - obj->setProperty("trackLevels", trackLevels); - obj->setProperty("trackClipping", audioEngine.getMeterClipStates()); - - // Get master level - float masterLevel = audioEngine.getMasterLevel(); - obj->setProperty("masterLevel", masterLevel); - obj->setProperty("masterClipping", audioEngine.getMasterClipLatched()); - - // Add timestamp - obj->setProperty("timestamp", juce::Time::currentTimeMillis()); - - // Emit custom event to JavaScript - emitFrontendEvent("meterUpdate", meterData); + // Detached plugin editors obtain their own small rack/tuner telemetry and + // do not consume the DAW-wide meter event. Avoid rebuilding every track's + // meter payload for those windows. + if (windowRole != WindowRole::pluginEditor) + { + juce::var meterData( + new juce::DynamicObject()); + auto* obj = meterData.getDynamicObject(); + + obj->setProperty( + "trackLevels", + audioEngine.getMeterLevels()); + obj->setProperty( + "midiInputLevels", + audioEngine.getMIDIInputLevels()); + obj->setProperty( + "trackClipping", + audioEngine.getMeterClipStates()); + obj->setProperty( + "masterLevel", + audioEngine.getMasterLevel()); + obj->setProperty( + "masterClipping", + audioEngine.getMasterClipLatched()); + obj->setProperty( + "timestamp", + juce::Time::currentTimeMillis()); + emitFrontendEvent( + "meterUpdate", + meterData); + } } //============================================================================== diff --git a/Source/MainComponent.h b/Source/MainComponent.h index 9f2c574..4076ebc 100644 --- a/Source/MainComponent.h +++ b/Source/MainComponent.h @@ -3,7 +3,11 @@ #include <JuceHeader.h> #include "AudioEngine.h" #include "AppUpdater.h" +#include <functional> +#include <map> #include <set> +#include <utility> +#include <vector> //============================================================================== /* @@ -24,7 +28,8 @@ class MainComponent : public juce::Component, { main, mixer, - midiEditor + midiEditor, + pluginEditor }; enum class FrontendStartupState @@ -59,6 +64,8 @@ class MainComponent : public juce::Component, std::function<juce::var(const juce::String&)> getMidiEditorWindowState; std::function<void(const juce::String&, const juce::var&)> publishMidiEditorUISnapshot; std::function<juce::var(const juce::String&)> getMidiEditorUISnapshot; + std::function<bool(const juce::String&, const juce::var&)> openPluginEditorWindow; + std::function<bool(const juce::String&, const juce::String&)> closePluginEditorWindow; }; //============================================================================== @@ -77,11 +84,22 @@ class MainComponent : public juce::Component, void timerCallback() override; void requestFrontendAppClose(); + void requestEmbeddedBrowserFocus(); + void prepareForSecondaryWindowClose(); + bool hasFrontendStartupSucceeded() const; + bool hasFrontendStartupReachedTerminalState() const; + juce::String getFrontendStartupStateDescription() const; static void broadcastEventToAll(const juce::String& eventId, const juce::var& payload = {}); static void broadcastEventToRole(WindowRole role, const juce::String& eventId, const juce::var& payload = {}); static juce::var buildStartupSelfTestReport(); static bool writeStartupSelfTestReport(const juce::File& reportFile); + static juce::var runNAMCatalogNativeRegression(); + static int runNAMLibraryManifestWriterRegressionChild( + const juce::File& manifestFile, + const juce::String& writerId, + const juce::File& readyFile, + const juce::File& startFile); #if JUCE_WINDOWS void emitExternalMediaDropTargetEvent(const juce::String& eventId, const juce::var& payload); @@ -113,6 +131,40 @@ class MainComponent : public juce::Component, juce::var buildStartupDiagnostics() const; void initializePitchRegressionJob(const juce::String& pitchRegressionJobPathIn); bool completePitchRegressionJob(const juce::var& result); + static std::string makeNAMModelMutationKey(const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::String& slot); + juce::uint64 beginNAMModelMutationRequest(const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::String& slot); + juce::uint64 beginNAMModelMutationRequests( + const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::StringArray& slots, + std::vector<std::pair<juce::String, juce::uint64>>& requests); + void invalidateNAMModelMutationRequests(const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::StringArray& slots); + bool isNAMModelMutationRequestCurrent(const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const juce::String& slot, + juce::uint64 generation); + std::shared_ptr<void> acquireNAMModelMutationPublicationLease( + const juce::String& trackId, + const juce::String& chainType, + int fxIndex, + const std::vector<std::pair<juce::String, juce::uint64>>& requests, + juce::uint64 topologyGeneration); + juce::var discardNAMPreviewIfUnused(juce::var recordPayload, + juce::var rackAddressPayload); + void runTone3000NativeTask( + std::function<juce::var()> task, + juce::WebBrowserComponent::NativeFunctionCompletion completion); #if JUCE_WINDOWS void installExternalMediaDropTarget(); bool isWaveformPreviewRequestCancelled(const juce::String& requestId) const; @@ -128,6 +180,7 @@ class MainComponent : public juce::Component, WindowCallbacks windowCallbacks; juce::File webuiDir; juce::WebBrowserComponent webView; + bool embeddedBrowserFocusRequestPending = false; juce::Label startupStatusMessage; juce::Label fallbackMessage; juce::TextButton startupRetryButton { "Retry" }; @@ -153,6 +206,36 @@ class MainComponent : public juce::Component, juce::ThreadPool noteRenderPool { 1 }; juce::ThreadPool fullClipHQPool { 1 }; juce::ThreadPool mediaPreviewPool { 2 }; + juce::ThreadPool clipPeakAnalysisPool { + 1, + juce::Thread::osDefaultStackSize, + juce::Thread::Priority::low + }; + juce::ThreadPool pluginScanPool { + 1, + juce::Thread::osDefaultStackSize, + juce::Thread::Priority::low + }; + std::atomic<bool> tone3000NativeCompletionsEnabled { true }; + std::shared_ptr<std::atomic<bool>> tone3000TaskCancellation { + std::make_shared<std::atomic<bool>>(false) + }; + juce::ThreadPool tone3000BridgePool { + 4, + juce::Thread::osDefaultStackSize, + juce::Thread::Priority::low + }; + std::atomic<bool> pluginScanRunning { false }; + // Keeps this window's jobs alive and sequenced. A process-wide gate in + // MainComponent.cpp serialises NAM mutations across every editor window. + // Model/IR parsing and prewarming can be CPU- and memory-intensive. Keep + // this single mutation worker below the audio callback's scheduling class + // so a model load cannot steal a 16-sample deadline. + juce::ThreadPool builtInStateMutationPool { + 1, + juce::Thread::osDefaultStackSize, + juce::Thread::Priority::low + }; juce::CriticalSection pitchCorrectionJobLock; juce::String activePreviewRequestGroup; juce::String activeNoteRenderRequestGroup; @@ -167,9 +250,12 @@ class MainComponent : public juce::Component, bool startupFallbackVisible = false; bool startupWatchdogActive = false; bool attemptedPackagedFrontendFallbackAfterLocalTimeout = false; + bool secondaryWindowClosing = false; StartupRepairAction startupRepairAction = StartupRepairAction::none; juce::String lastAiToolsStatusDigest; double lastAiToolsStatusEmitMs = 0.0; + double lastAiToolsStatusPollMs = 0.0; + bool lastAiToolsInstallInProgress = false; juce::File pitchRegressionJobFile; juce::var pitchRegressionJob; bool pitchRegressionJobConsumed = false; diff --git a/Source/Metronome.cpp b/Source/Metronome.cpp index 7080f2a..bf96247 100644 --- a/Source/Metronome.cpp +++ b/Source/Metronome.cpp @@ -10,71 +10,341 @@ #include "Metronome.h" +#include <limits> + +namespace +{ +class ScopedClickDataReader final +{ +public: + explicit ScopedClickDataReader( + std::atomic<std::uint32_t>& readersToUse) noexcept + : readers(readersToUse) + { + readers.fetch_add(1, std::memory_order_seq_cst); + } + + ~ScopedClickDataReader() + { + readers.fetch_sub(1, std::memory_order_seq_cst); + } + + ScopedClickDataReader( + const ScopedClickDataReader&) = delete; + ScopedClickDataReader& operator=( + const ScopedClickDataReader&) = delete; + +private: + std::atomic<std::uint32_t>& readers; +}; +} + Metronome::Metronome() { formatManager.registerBasicFormats(); - generateClickSounds(); + auto initialClickData = + createDefaultClickData( + sampleRate.load(std::memory_order_relaxed)); + clickDataOwner = initialClickData; + clickDataForAudio.store( + initialClickData.get(), std::memory_order_seq_cst); } Metronome::~Metronome() { + clickDataForAudio.store( + nullptr, std::memory_order_seq_cst); + jassert( + clickDataAudioReaders.load( + std::memory_order_seq_cst) == 0); + + const juce::ScopedLock publicationGuard( + clickDataPublicationLock); + retiredClickDataOwners.clear(); + clickDataOwner.reset(); } void Metronome::prepareToPlay(double newSampleRate, int samplesPerBlock) { juce::ignoreUnused(samplesPerBlock); - if (sampleRate != newSampleRate) + if (!std::isfinite(newSampleRate) + || newSampleRate <= 0.0) + { + return; + } + + const juce::ScopedLock mutationGuard( + clickDataMutationLock); + const double previousSampleRate = + sampleRate.exchange( + newSampleRate, + std::memory_order_acq_rel); + if (std::abs(previousSampleRate - newSampleRate) <= 1.0e-9) + return; + + // Rebuild all buffers off the audio thread. Custom files are reloaded at + // the new device rate; if a file has disappeared, retain its previous + // immutable buffer rather than publishing a partial or empty click. + const auto previousData = + getClickDataSnapshot(); + auto nextData = + createDefaultClickData(newSampleRate); + if (previousData != nullptr) { - sampleRate = newSampleRate; - generateClickSounds(); // Regenerate for new sample rate + nextData->accentBeats = + previousData->accentBeats; + nextData->usingCustomClick = + previousData->usingCustomClick; + nextData->usingCustomAccent = + previousData->usingCustomAccent; + nextData->customClickPath = + previousData->customClickPath; + nextData->customAccentPath = + previousData->customAccentPath; + + if (previousData->usingCustomClick) + { + juce::AudioBuffer<float> customClick; + if (loadSoundFromFile( + previousData->customClickPath, + newSampleRate, + customClick)) + { + nextData->lowClickBuffer = + std::move(customClick); + } + else + { + nextData->lowClickBuffer = + previousData->lowClickBuffer; + } + } + + if (previousData->usingCustomAccent) + { + juce::AudioBuffer<float> customAccent; + if (loadSoundFromFile( + previousData->customAccentPath, + newSampleRate, + customAccent)) + { + nextData->highClickBuffer = + std::move(customAccent); + } + else + { + nextData->highClickBuffer = + previousData->highClickBuffer; + } + } } + publishClickData(nextData); } -void Metronome::generateClickSounds() +std::uint64_t Metronome::packTimeSignature( + int numeratorToPack, + int denominatorToPack) noexcept { - // Generate 20ms click - int samples = static_cast<int>(sampleRate * 0.05); // 50ms just in case - highClickBuffer.setSize(1, samples); - lowClickBuffer.setSize(1, samples); + return + (static_cast<std::uint64_t>( + static_cast<std::uint32_t>( + numeratorToPack)) + << 32) + | static_cast<std::uint64_t>( + static_cast<std::uint32_t>( + denominatorToPack)); +} + +int Metronome::unpackNumerator( + std::uint64_t timeSignature) noexcept +{ + return static_cast<int>( + static_cast<std::uint32_t>( + timeSignature >> 32)); +} + +int Metronome::unpackDenominator( + std::uint64_t timeSignature) noexcept +{ + return static_cast<int>( + static_cast<std::uint32_t>( + timeSignature & 0xffffffffULL)); +} + +void Metronome::generateDefaultClickSounds( + double targetSampleRate, + juce::AudioBuffer<float>& highClick, + juce::AudioBuffer<float>& lowClick) +{ + const double safeSampleRate = + std::isfinite(targetSampleRate) + && targetSampleRate > 0.0 + ? targetSampleRate + : 44100.0; + const int samples = juce::jmax( + 1, + static_cast<int>( + safeSampleRate * 0.05)); + highClick.setSize(1, samples); + lowClick.setSize(1, samples); - highClickBuffer.clear(); - lowClickBuffer.clear(); + highClick.clear(); + lowClick.clear(); - auto* highWrite = highClickBuffer.getWritePointer(0); - auto* lowWrite = lowClickBuffer.getWritePointer(0); + auto* highWrite = highClick.getWritePointer(0); + auto* lowWrite = lowClick.getWritePointer(0); // High click: 1500Hz sine wave with exponential decay // Low click: 800Hz sine wave with exponential decay - double highFreq = 1500.0; - double lowFreq = 800.0; - double decay = 0.002; // Decay rate + constexpr double highFreq = 1500.0; + constexpr double lowFreq = 800.0; for (int i = 0; i < samples; ++i) { - double t = i / sampleRate; - double envelope = std::exp(-i * decay); // Exponential decay (per sample approximation) - // Correct decay: exp(-t * k) - // Let's use simple linear decay for safety or standard ADSR? - // Simple exp decay: - envelope = std::exp(-50.0 * t); // Decays effectively in ~100ms + const double t = + static_cast<double>(i) + / safeSampleRate; + const double envelope = + std::exp(-50.0 * t); - highWrite[i] = (float)(std::sin(2.0 * juce::MathConstants<double>::pi * highFreq * t) * envelope); - lowWrite[i] = (float)(std::sin(2.0 * juce::MathConstants<double>::pi * lowFreq * t) * envelope); + highWrite[i] = static_cast<float>( + std::sin( + 2.0 + * juce::MathConstants<double>::pi + * highFreq + * t) + * envelope); + lowWrite[i] = static_cast<float>( + std::sin( + 2.0 + * juce::MathConstants<double>::pi + * lowFreq + * t) + * envelope); + } +} + +std::shared_ptr<Metronome::ClickData> +Metronome::createDefaultClickData( + double targetSampleRate) const +{ + auto clickData = + std::make_shared<ClickData>(); + generateDefaultClickSounds( + targetSampleRate, + clickData->highClickBuffer, + clickData->lowClickBuffer); + return clickData; +} + +std::shared_ptr<const Metronome::ClickData> +Metronome::getClickDataSnapshot() const +{ + const juce::ScopedLock publicationGuard( + clickDataPublicationLock); + return clickDataOwner; +} + +void Metronome::publishClickData( + std::shared_ptr<const ClickData> nextData) +{ + if (nextData == nullptr) + return; + + std::vector<std::shared_ptr<const ClickData>> + ownersToReclaim; + { + const juce::ScopedLock publicationGuard( + clickDataPublicationLock); + if (clickDataAudioReaders.load( + std::memory_order_seq_cst) == 0) + { + ownersToReclaim.swap( + retiredClickDataOwners); + } + + const auto previousData = + clickDataOwner; + clickDataOwner = nextData; + clickDataForAudio.store( + nextData.get(), + std::memory_order_seq_cst); + if (previousData != nullptr + && previousData.get() + != nextData.get()) + { + retiredClickDataOwners.push_back( + previousData); + } } + // ownersToReclaim destructs here, outside the publication lock and away + // from the audio callback. } void Metronome::getNextAudioBlock(juce::AudioBuffer<float>& buffer, double currentSamplePosition) { - if (!enabled) return; + if (! enabled.load(std::memory_order_acquire)) + return; - int numSamples = buffer.getNumSamples(); - const double denominatorScale = denominator > 0 ? (4.0 / static_cast<double>(denominator)) : 1.0; - double samplesPerBeat = (60.0 / bpm) * sampleRate * denominatorScale; + const int numSamples = + buffer.getNumSamples(); + if (numSamples <= 0 + || buffer.getNumChannels() <= 0) + { + return; + } - // Safety check - if (samplesPerBeat <= 0.0) return; + const double blockBpm = + bpm.load(std::memory_order_relaxed); + const double blockSampleRate = + sampleRate.load(std::memory_order_relaxed); + const float blockVolume = + volume.load(std::memory_order_relaxed); + const auto blockTimeSignature = + packedTimeSignature.load( + std::memory_order_acquire); + const int blockNumerator = + unpackNumerator(blockTimeSignature); + const int blockDenominator = + unpackDenominator(blockTimeSignature); + + if (!std::isfinite(blockBpm) + || !std::isfinite(blockSampleRate) + || !std::isfinite(blockVolume) + || blockBpm <= 0.0 + || blockSampleRate <= 0.0 + || blockNumerator <= 0 + || blockDenominator <= 0) + { + return; + } + + const double denominatorScale = + 4.0 + / static_cast<double>( + blockDenominator); + const double samplesPerBeat = + (60.0 / blockBpm) + * blockSampleRate + * denominatorScale; + if (!std::isfinite(samplesPerBeat) + || samplesPerBeat <= 0.0) + { + return; + } + + // The immutable click owner is reclaimed only after every callback reader + // has left. This path performs no shared_ptr atomic operation, lock, + // allocation, or logging. + const ScopedClickDataReader clickDataReadGuard( + clickDataAudioReaders); + const auto* const clickData = + clickDataForAudio.load( + std::memory_order_seq_cst); + if (clickData == nullptr) + return; auto* leftConfig = buffer.getWritePointer(0); auto* rightConfig = buffer.getNumChannels() > 1 ? buffer.getWritePointer(1) : nullptr; @@ -105,14 +375,25 @@ void Metronome::getNextAudioBlock(juce::AudioBuffer<float>& buffer, double curre if (isBeatStart && !isClicking) { // Beat detected - int beatInBar = currentBeatIdx % numerator; // 0-indexed: 0 is the first beat + const int beatInBar = + ((currentBeatIdx % blockNumerator) + + blockNumerator) + % blockNumerator; isClicking = true; clickSampleCounter = 0; // Use accent array to determine if this beat should be high-pitched - if (beatInBar < (int)accentBeats.size()) { - isHighClick = accentBeats[beatInBar]; - } else { + if (beatInBar + < static_cast<int>( + clickData->accentBeats.size())) + { + isHighClick = + clickData->accentBeats[ + static_cast<size_t>( + beatInBar)]; + } + else + { // Fallback: only accent beat 0 if array doesn't cover this beat isHighClick = (beatInBar == 0); } @@ -122,11 +403,19 @@ void Metronome::getNextAudioBlock(juce::AudioBuffer<float>& buffer, double curre if (isClicking) { float clickValue = 0.0f; - const auto& sourceBuffer = isHighClick ? highClickBuffer : lowClickBuffer; + const auto& sourceBuffer = + isHighClick + ? clickData->highClickBuffer + : clickData->lowClickBuffer; - if (clickSampleCounter < sourceBuffer.getNumSamples()) + if (sourceBuffer.getNumChannels() > 0 + && clickSampleCounter + < sourceBuffer.getNumSamples()) { - clickValue = sourceBuffer.getReadPointer(0)[clickSampleCounter] * volume; + clickValue = + sourceBuffer.getReadPointer(0)[ + clickSampleCounter] + * blockVolume; clickSampleCounter++; } else @@ -146,42 +435,134 @@ void Metronome::getNextAudioBlock(juce::AudioBuffer<float>& buffer, double curre void Metronome::setBpm(double newBpm) { - if (newBpm > 0) - bpm = newBpm; + if (std::isfinite(newBpm) + && newBpm > 0.0) + { + bpm.store( + newBpm, + std::memory_order_relaxed); + } } void Metronome::setTimeSignature(int newNumerator, int newDenominator) { - if (newNumerator > 0) numerator = newNumerator; - if (newDenominator > 0) denominator = newDenominator; + const auto previous = + packedTimeSignature.load( + std::memory_order_acquire); + const int safeNumerator = + newNumerator > 0 + ? newNumerator + : unpackNumerator(previous); + const int safeDenominator = + newDenominator > 0 + ? newDenominator + : unpackDenominator(previous); + packedTimeSignature.store( + packTimeSignature( + safeNumerator, + safeDenominator), + std::memory_order_release); } void Metronome::setVolume(float newVolume) { - volume = newVolume; + if (std::isfinite(newVolume)) + { + volume.store( + newVolume, + std::memory_order_relaxed); + } } void Metronome::setEnabled(bool shouldBeEnabled) { - enabled = shouldBeEnabled; + enabled.store( + shouldBeEnabled, + std::memory_order_release); } void Metronome::setAccentBeats(const std::vector<bool>& accents) { - accentBeats = accents; - // Ensure we always have at least one element and beat 0 is always accented - if (accentBeats.empty()) { - accentBeats.resize(numerator, false); - } - if (!accentBeats.empty()) { - accentBeats[0] = true; // Beat 1 is always accented + std::vector<bool> safeAccents = + accents; + if (safeAccents.empty()) + { + safeAccents.resize( + static_cast<size_t>( + juce::jmax( + 1, + getNumerator())), + false); } + safeAccents[0] = true; + + const juce::ScopedLock mutationGuard( + clickDataMutationLock); + const auto currentData = + getClickDataSnapshot(); + auto nextData = + currentData != nullptr + ? std::make_shared<ClickData>( + *currentData) + : createDefaultClickData( + sampleRate.load( + std::memory_order_relaxed)); + nextData->accentBeats = + std::move(safeAccents); + publishClickData(nextData); +} + +std::vector<bool> Metronome::getAccentBeats() const +{ + const auto clickData = + getClickDataSnapshot(); + if (clickData != nullptr) + return clickData->accentBeats; + + return { true }; +} + +int Metronome::getNumerator() const +{ + return unpackNumerator( + packedTimeSignature.load( + std::memory_order_acquire)); +} + +int Metronome::getDenominator() const +{ + return unpackDenominator( + packedTimeSignature.load( + std::memory_order_acquire)); } bool Metronome::renderToFile(const juce::File& outputFile, double startTimeSeconds, double endTimeSeconds) { + const double renderSampleRate = + sampleRate.load(std::memory_order_relaxed); + if (!std::isfinite(renderSampleRate) + || renderSampleRate <= 0.0 + || !std::isfinite(startTimeSeconds) + || !std::isfinite(endTimeSeconds)) + { + return false; + } + // Calculate total samples - int totalSamples = static_cast<int>((endTimeSeconds - startTimeSeconds) * sampleRate); + const double requestedSamples = + (endTimeSeconds - startTimeSeconds) + * renderSampleRate; + if (!std::isfinite(requestedSamples) + || requestedSamples <= 0.0 + || requestedSamples + > static_cast<double>( + std::numeric_limits<int>::max())) + { + return false; + } + + const int totalSamples = + static_cast<int>(requestedSamples); if (totalSamples <= 0) return false; @@ -190,43 +571,41 @@ bool Metronome::renderToFile(const juce::File& outputFile, double startTimeSecon outputFile.deleteFile(); juce::WavAudioFormat wavFormat; - auto outputStream = std::make_unique<juce::FileOutputStream>(outputFile); - if (outputStream->failedToOpen()) + std::unique_ptr<juce::OutputStream> outputStream = std::make_unique<juce::FileOutputStream>(outputFile); + if (static_cast<juce::FileOutputStream&>(*outputStream).failedToOpen()) return false; - std::unique_ptr<juce::AudioFormatWriter> writer( - wavFormat.createWriterFor( - outputStream.get(), - sampleRate, - 2, // stereo - 16, // bit depth - {}, // metadata - 0 // quality - ) - ); + auto writer = wavFormat.createWriterFor( + outputStream, + juce::AudioFormatWriterOptions() + .withSampleRate(renderSampleRate) + .withNumChannels(2) + .withBitsPerSample(16)); if (!writer) return false; - outputStream.release(); // Writer takes ownership of the stream - // Save and reset playback state for clean offline render int savedClickCounter = clickSampleCounter; bool savedIsClicking = isClicking; bool savedIsHighClick = isHighClick; double savedLastPos = lastSamplePosition; - bool savedEnabled = enabled; + const bool savedEnabled = + enabled.load(std::memory_order_acquire); clickSampleCounter = 0; isClicking = false; isHighClick = false; lastSamplePosition = -1.0; - enabled = true; // Force enabled for rendering + enabled.store( + true, + std::memory_order_release); // Force enabled for rendering // Process in blocks const int blockSize = 512; juce::AudioBuffer<float> buffer(2, blockSize); - double currentPos = startTimeSeconds * sampleRate; + double currentPos = + startTimeSeconds * renderSampleRate; int samplesRemaining = totalSamples; while (samplesRemaining > 0) @@ -256,7 +635,9 @@ bool Metronome::renderToFile(const juce::File& outputFile, double startTimeSecon isClicking = savedIsClicking; isHighClick = savedIsHighClick; lastSamplePosition = savedLastPos; - enabled = savedEnabled; + enabled.store( + savedEnabled, + std::memory_order_release); return true; } @@ -265,8 +646,17 @@ bool Metronome::renderToFile(const juce::File& outputFile, double startTimeSecon // Phase 9C: Custom Click Sounds // ============================================================================= -bool Metronome::loadSoundFromFile(const juce::String& filePath, juce::AudioBuffer<float>& targetBuffer) +bool Metronome::loadSoundFromFile( + const juce::String& filePath, + double targetSampleRate, + juce::AudioBuffer<float>& targetBuffer) { + if (!std::isfinite(targetSampleRate) + || targetSampleRate <= 0.0) + { + return false; + } + juce::File audioFile(filePath); if (!audioFile.existsAsFile()) return false; @@ -276,24 +666,69 @@ bool Metronome::loadSoundFromFile(const juce::String& filePath, juce::AudioBuffe if (!reader) return false; - // Limit click sample to 2 seconds max - auto maxSamples = (juce::int64)(reader->sampleRate * 2.0); - auto samplesToRead = std::min(reader->lengthInSamples, maxSamples); + if (!std::isfinite(reader->sampleRate) + || reader->sampleRate <= 0.0 + || reader->numChannels == 0) + { + return false; + } - if (samplesToRead <= 0) + // Limit click sample to 2 seconds max + const auto maxSamples = + static_cast<juce::int64>( + reader->sampleRate * 2.0); + const auto samplesToRead = + std::min( + reader->lengthInSamples, + maxSamples); + + if (samplesToRead <= 0 + || samplesToRead + > static_cast<juce::int64>( + std::numeric_limits<int>::max())) return false; // Read into a temp buffer at the file's native sample rate - juce::AudioBuffer<float> fileBuffer((int)reader->numChannels, (int)samplesToRead); - reader->read(&fileBuffer, 0, (int)samplesToRead, 0, true, true); + juce::AudioBuffer<float> fileBuffer( + static_cast<int>(reader->numChannels), + static_cast<int>(samplesToRead)); + if (!reader->read( + &fileBuffer, + 0, + static_cast<int>(samplesToRead), + 0, + true, + true)) + { + return false; + } // Mix to mono if multi-channel - int outSamples = (int)samplesToRead; + int outSamples = + static_cast<int>(samplesToRead); // If sample rate differs, resample to match metronome's sample rate - if (std::abs(reader->sampleRate - sampleRate) > 1.0) + if (std::abs( + reader->sampleRate + - targetSampleRate) > 1.0) { - double ratio = sampleRate / reader->sampleRate; - outSamples = (int)(samplesToRead * ratio); + const double ratio = + targetSampleRate + / reader->sampleRate; + const double outputLength = + static_cast<double>(samplesToRead) + * ratio; + if (!std::isfinite(outputLength) + || outputLength <= 0.0 + || outputLength + > static_cast<double>( + std::numeric_limits<int>::max())) + { + return false; + } + outSamples = + juce::jmax( + 1, + static_cast<int>(outputLength)); } targetBuffer.setSize(1, outSamples); @@ -301,10 +736,14 @@ bool Metronome::loadSoundFromFile(const juce::String& filePath, juce::AudioBuffe auto* outWrite = targetBuffer.getWritePointer(0); - if (std::abs(reader->sampleRate - sampleRate) > 1.0) + if (std::abs( + reader->sampleRate + - targetSampleRate) > 1.0) { // Simple linear interpolation resample - double ratio = reader->sampleRate / sampleRate; + double ratio = + reader->sampleRate + / targetSampleRate; for (int i = 0; i < outSamples; ++i) { double srcPos = i * ratio; @@ -340,52 +779,102 @@ bool Metronome::loadSoundFromFile(const juce::String& filePath, juce::AudioBuffe bool Metronome::setClickSound(const juce::String& filePath) { + const juce::ScopedLock mutationGuard( + clickDataMutationLock); + const double targetSampleRate = + sampleRate.load(std::memory_order_relaxed); + juce::AudioBuffer<float> replacementBuffer; + if (filePath.isEmpty()) { - // Reset to default - usingCustomClick = false; - customClickPath.clear(); - generateClickSounds(); // Regenerate defaults (only overwrites lowClickBuffer if not custom) - return true; + juce::AudioBuffer<float> unusedHighClick; + generateDefaultClickSounds( + targetSampleRate, + unusedHighClick, + replacementBuffer); } - - juce::AudioBuffer<float> tempBuffer; - if (loadSoundFromFile(filePath, tempBuffer)) + else if (!loadSoundFromFile( + filePath, + targetSampleRate, + replacementBuffer)) { - lowClickBuffer = std::move(tempBuffer); - usingCustomClick = true; - customClickPath = filePath; - return true; + return false; } - return false; + + const auto currentData = + getClickDataSnapshot(); + auto nextData = + currentData != nullptr + ? std::make_shared<ClickData>( + *currentData) + : createDefaultClickData( + targetSampleRate); + nextData->lowClickBuffer = + std::move(replacementBuffer); + nextData->usingCustomClick = + filePath.isNotEmpty(); + nextData->customClickPath = + filePath; + publishClickData(nextData); + return true; } bool Metronome::setAccentSound(const juce::String& filePath) { + const juce::ScopedLock mutationGuard( + clickDataMutationLock); + const double targetSampleRate = + sampleRate.load(std::memory_order_relaxed); + juce::AudioBuffer<float> replacementBuffer; + if (filePath.isEmpty()) { - usingCustomAccent = false; - customAccentPath.clear(); - generateClickSounds(); - return true; + juce::AudioBuffer<float> unusedLowClick; + generateDefaultClickSounds( + targetSampleRate, + replacementBuffer, + unusedLowClick); } - - juce::AudioBuffer<float> tempBuffer; - if (loadSoundFromFile(filePath, tempBuffer)) + else if (!loadSoundFromFile( + filePath, + targetSampleRate, + replacementBuffer)) { - highClickBuffer = std::move(tempBuffer); - usingCustomAccent = true; - customAccentPath = filePath; - return true; + return false; } - return false; + + const auto currentData = + getClickDataSnapshot(); + auto nextData = + currentData != nullptr + ? std::make_shared<ClickData>( + *currentData) + : createDefaultClickData( + targetSampleRate); + nextData->highClickBuffer = + std::move(replacementBuffer); + nextData->usingCustomAccent = + filePath.isNotEmpty(); + nextData->customAccentPath = + filePath; + publishClickData(nextData); + return true; } void Metronome::resetToDefaultSounds() { - usingCustomClick = false; - usingCustomAccent = false; - customClickPath.clear(); - customAccentPath.clear(); - generateClickSounds(); + const juce::ScopedLock mutationGuard( + clickDataMutationLock); + auto nextData = + createDefaultClickData( + sampleRate.load( + std::memory_order_relaxed)); + const auto currentData = + getClickDataSnapshot(); + if (currentData != nullptr) + { + nextData->accentBeats = + currentData->accentBeats; + } + publishClickData(nextData); } diff --git a/Source/Metronome.h b/Source/Metronome.h index 510953e..6ee0c6a 100644 --- a/Source/Metronome.h +++ b/Source/Metronome.h @@ -11,6 +11,10 @@ #pragma once #include <JuceHeader.h> +#include <atomic> +#include <cstdint> +#include <memory> +#include <vector> class Metronome { @@ -28,7 +32,10 @@ class Metronome void setVolume(float newVolume); void setEnabled(bool shouldBeEnabled); void setAccentBeats(const std::vector<bool>& accents); - bool isEnabled() const { return enabled; } + bool isEnabled() const + { + return enabled.load(std::memory_order_acquire); + } // Custom click sounds (Phase 9C) bool setClickSound(const juce::String& filePath); // Load custom WAV for regular beats @@ -36,28 +43,47 @@ class Metronome void resetToDefaultSounds(); // Restore synthesized clicks // Getters for offline rendering - const std::vector<bool>& getAccentBeats() const { return accentBeats; } - float getVolume() const { return volume; } - double getBpm() const { return bpm; } - int getNumerator() const { return numerator; } - int getDenominator() const { return denominator; } + std::vector<bool> getAccentBeats() const; + float getVolume() const + { + return volume.load(std::memory_order_relaxed); + } + double getBpm() const + { + return bpm.load(std::memory_order_relaxed); + } + int getNumerator() const; + int getDenominator() const; // Render metronome audio to a WAV file offline (for export/render track) bool renderToFile(const juce::File& outputFile, double startTimeSeconds, double endTimeSeconds); private: - double sampleRate = 44100.0; - double bpm = 120.0; - int numerator = 4; - int denominator = 4; - float volume = 0.5f; - bool enabled = false; - std::vector<bool> accentBeats = {true, false, false, false}; // Default 4/4 with only beat 1 accented - - // Buffers for cached click sounds - juce::AudioBuffer<float> highClickBuffer; - juce::AudioBuffer<float> lowClickBuffer; - + struct ClickData + { + std::vector<bool> accentBeats { + true, false, false, false + }; + juce::AudioBuffer<float> highClickBuffer; + juce::AudioBuffer<float> lowClickBuffer; + bool usingCustomClick = false; + bool usingCustomAccent = false; + juce::String customClickPath; + juce::String customAccentPath; + }; + + static constexpr std::uint64_t defaultTimeSignature = + (static_cast<std::uint64_t>(4) << 32) + | static_cast<std::uint64_t>(4); + + std::atomic<double> sampleRate { 44100.0 }; + std::atomic<double> bpm { 120.0 }; + std::atomic<std::uint64_t> packedTimeSignature { + defaultTimeSignature + }; + std::atomic<float> volume { 0.5f }; + std::atomic<bool> enabled { false }; + // Playback state int clickSampleCounter = 0; // Current position within the click sound bool isClicking = false; // Are we currently playing a click? @@ -65,14 +91,39 @@ class Metronome double lastSamplePosition = -1.0; // Track last position to detect playback restart // Internal helpers - void generateClickSounds(); - bool loadSoundFromFile(const juce::String& filePath, juce::AudioBuffer<float>& targetBuffer); - - // Custom click sound state - bool usingCustomClick = false; - bool usingCustomAccent = false; - juce::String customClickPath; - juce::String customAccentPath; + static std::uint64_t packTimeSignature( + int numerator, int denominator) noexcept; + static int unpackNumerator( + std::uint64_t timeSignature) noexcept; + static int unpackDenominator( + std::uint64_t timeSignature) noexcept; + static void generateDefaultClickSounds( + double targetSampleRate, + juce::AudioBuffer<float>& highClick, + juce::AudioBuffer<float>& lowClick); + std::shared_ptr<ClickData> createDefaultClickData( + double targetSampleRate) const; + std::shared_ptr<const ClickData> + getClickDataSnapshot() const; + void publishClickData( + std::shared_ptr<const ClickData> nextData); + bool loadSoundFromFile( + const juce::String& filePath, + double targetSampleRate, + juce::AudioBuffer<float>& targetBuffer); + + // Control-side writes are serialised independently from publication. + // The audio thread never acquires either lock. + mutable juce::CriticalSection clickDataPublicationLock; + juce::CriticalSection clickDataMutationLock; + std::shared_ptr<const ClickData> clickDataOwner; + std::atomic<const ClickData*> clickDataForAudio { + nullptr + }; + std::atomic<std::uint32_t> clickDataAudioReaders { 0 }; + std::vector<std::shared_ptr<const ClickData>> + retiredClickDataOwners; + juce::AudioFormatManager formatManager; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(Metronome) diff --git a/Source/MixerWindowManager.cpp b/Source/MixerWindowManager.cpp index 13daff8..ad33d01 100644 --- a/Source/MixerWindowManager.cpp +++ b/Source/MixerWindowManager.cpp @@ -17,17 +17,23 @@ juce::Rectangle<int> sanitiseWindowBounds(const juce::Rectangle<int>& requested, if (auto* display = juce::Desktop::getInstance().getDisplays().getPrimaryDisplay()) { - const auto area = display->userArea; + const auto area = display->userBounds.getSmallestIntegerContainer(); if (bounds.getWidth() > area.getWidth()) bounds.setWidth(area.getWidth()); if (bounds.getHeight() > area.getHeight()) bounds.setHeight(area.getHeight()); - if (!area.contains(bounds)) + if (! area.contains(bounds)) bounds = bounds.withPosition(area.getX() + 40, area.getY() + 40); } return bounds; } + +juce::String describeBounds(const juce::Rectangle<int>& bounds) +{ + return juce::String(bounds.getX()) + "," + juce::String(bounds.getY()) + + " " + juce::String(bounds.getWidth()) + "x" + juce::String(bounds.getHeight()); +} } class MixerWindowManager::MixerWindow : public juce::DocumentWindow @@ -50,10 +56,33 @@ class MixerWindowManager::MixerWindow : public juce::DocumentWindow owner.close(); } + MainComponent* getHostedComponent() const + { + return dynamic_cast<MainComponent*>(getContentComponent()); + } + + void requestHostedBrowserFocus() + { + if (auto* component = getHostedComponent()) + component->requestEmbeddedBrowserFocus(); + } + + void activeWindowStatusChanged() override + { + juce::DocumentWindow::activeWindowStatusChanged(); + + if (isActiveWindow()) + requestHostedBrowserFocus(); + } + private: MixerWindowManager& owner; }; +int MixerWindowManager::globalCloseDepth = 0; +int MixerWindowManager::globalCreateDepth = 0; +juce::Array<MixerWindowManager*> MixerWindowManager::managersWithPendingRequests; + MixerWindowManager::MixerWindowManager(ComponentFactory componentFactoryIn, ClosedCallback closedCallbackIn, juce::String windowTitleIn, @@ -71,97 +100,212 @@ MixerWindowManager::MixerWindowManager(ComponentFactory componentFactoryIn, MixerWindowManager::~MixerWindowManager() { - close(); + stopTimer(); + managersWithPendingRequests.removeFirstMatchingValue(this); + pendingRequest = {}; + + if (mixerWindow != nullptr) + { + juce::Logger::writeToLog("Secondary window destroyed during manager shutdown: " + + windowTitle + " state=" + getStateDescription()); + if (auto* hosted = mixerWindow->getHostedComponent()) + hosted->prepareForSecondaryWindowClose(); + mixerWindow->setVisible(false); + mixerWindow = nullptr; + } + + if (closingWindow != nullptr) + { + juce::Logger::writeToLog("Secondary closing window destroyed during manager shutdown: " + + windowTitle + " state=" + getStateDescription()); + if (auto* hosted = closingWindow->getHostedComponent()) + hosted->prepareForSecondaryWindowClose(); + closingWindow->setVisible(false); + closingWindow = nullptr; + } + + releaseGlobalCloseSlot(); + state = WindowState::idle; } bool MixerWindowManager::open(const juce::Rectangle<int>& bounds) { + if (! ensureMessageThread("open")) + return false; + const auto targetBounds = sanitiseWindowBounds(bounds, defaultBounds, minWidth, minHeight); + if (state == WindowState::closing + || state == WindowState::retired + || (mixerWindow == nullptr && isGlobalLifecycleBusy())) + { + queuePendingRequest(PendingRequest::Type::open, targetBounds); + return true; + } + if (mixerWindow != nullptr) { mixerWindow->setBounds(targetBounds); mixerWindow->setVisible(true); mixerWindow->toFront(true); + mixerWindow->requestHostedBrowserFocus(); + setState(WindowState::visible, "open existing bounds=" + describeBounds(targetBounds)); + scheduleStartupNudge(); return true; } - if (!componentFactory) - return false; - - auto content = componentFactory(); - if (content == nullptr) - return false; - - mixerWindow = std::make_unique<MixerWindow>(*this, std::move(content)); - mixerWindow->setBounds(targetBounds); - mixerWindow->setVisible(true); - mixerWindow->toFront(true); - - juce::Component::SafePointer<juce::DocumentWindow> safeWindow(mixerWindow.get()); - juce::Timer::callAfterDelay(600, [safeWindow]() - { - if (safeWindow != nullptr) - { - const auto boundsNow = safeWindow->getBounds(); - safeWindow->setBounds(boundsNow.withWidth(boundsNow.getWidth() + 1)); - safeWindow->setBounds(boundsNow); - } - }); - - return true; + setState(WindowState::creating, "open requested bounds=" + describeBounds(targetBounds)); + return createWindow(targetBounds, true); } bool MixerWindowManager::prewarm(const juce::Rectangle<int>& bounds) { + if (! ensureMessageThread("prewarm")) + return false; + const auto targetBounds = sanitiseWindowBounds(bounds, defaultBounds, minWidth, minHeight); + if (state == WindowState::closing + || state == WindowState::retired + || (mixerWindow == nullptr && isGlobalLifecycleBusy())) + { + queuePendingRequest(PendingRequest::Type::prewarm, targetBounds); + return true; + } + if (mixerWindow != nullptr) { mixerWindow->setBounds(targetBounds); mixerWindow->setVisible(false); + setState(WindowState::readyHidden, "prewarm existing bounds=" + describeBounds(targetBounds)); return true; } - if (!componentFactory) - return false; - - auto content = componentFactory(); - if (content == nullptr) - return false; - - mixerWindow = std::make_unique<MixerWindow>(*this, std::move(content)); - mixerWindow->setBounds(targetBounds); - mixerWindow->setVisible(false); - return true; + setState(WindowState::creating, "prewarm requested bounds=" + describeBounds(targetBounds)); + return createWindow(targetBounds, false); } bool MixerWindowManager::close() { - if (mixerWindow == nullptr) + if (! ensureMessageThread("close")) return false; - handleWindowClosed(); + if (state == WindowState::closing || state == WindowState::retired) + { + juce::Logger::writeToLog("Secondary window close ignored because close is already in progress: " + + windowTitle + " state=" + getStateDescription()); + return true; + } + + if (mixerWindow == nullptr) + { + if (pendingRequest.type != PendingRequest::Type::none) + { + juce::Logger::writeToLog("Secondary window close cancelled pending request before creation: " + + windowTitle + " state=" + getStateDescription()); + pendingRequest = {}; + managersWithPendingRequests.removeFirstMatchingValue(this); + return true; + } + + juce::Logger::writeToLog("Secondary window close ignored because no active window exists: " + + windowTitle + " state=" + getStateDescription()); + return state == WindowState::idle; + } + + juce::Logger::writeToLog("Secondary window close requested: " + windowTitle + + " state=" + getStateDescription()); + + if (auto* hosted = mixerWindow->getHostedComponent()) + { + if (! hosted->hasFrontendStartupReachedTerminalState()) + { + closePendingUntilStartupSettles = true; + closeStartedMs = juce::Time::currentTimeMillis(); + setState(WindowState::closing, + "close pending until frontend startup settles startupState=" + hosted->getFrontendStartupStateDescription()); + + if (! countedGlobalClose) + { + countedGlobalClose = true; + ++globalCloseDepth; + juce::Logger::writeToLog("Secondary window global close depth: " + + juce::String(globalCloseDepth) + + " after pending close " + windowTitle); + } + + startTimer(closeReadinessPollMs); + return true; + } + } + + beginClose(true); return true; } bool MixerWindowManager::focus() { + if (! ensureMessageThread("focus")) + return false; + + if (state == WindowState::closing || state == WindowState::retired) + { + queuePendingRequest(PendingRequest::Type::focus); + return true; + } + + if (mixerWindow == nullptr + && (isGlobalLifecycleBusy() || pendingRequest.type != PendingRequest::Type::none)) + { + queuePendingRequest(PendingRequest::Type::focus); + return true; + } + if (mixerWindow == nullptr) + { + juce::Logger::writeToLog("Secondary window focus ignored because no active window exists: " + + windowTitle + " state=" + getStateDescription()); return false; + } mixerWindow->setVisible(true); mixerWindow->toFront(true); + mixerWindow->requestHostedBrowserFocus(); + setState(WindowState::visible, "focus"); + scheduleStartupNudge(); return true; } bool MixerWindowManager::hide() { - if (mixerWindow == nullptr) + if (! ensureMessageThread("hide")) return false; + if (state == WindowState::closing || state == WindowState::retired) + return true; + + if (state == WindowState::readyHidden) + return true; + + if (mixerWindow == nullptr) + { + if (pendingRequest.type != PendingRequest::Type::none) + { + juce::Logger::writeToLog("Secondary window hide cancelled pending request before creation: " + + windowTitle + " state=" + getStateDescription()); + pendingRequest = {}; + managersWithPendingRequests.removeFirstMatchingValue(this); + return true; + } + + juce::Logger::writeToLog("Secondary window hide ignored because no active window exists: " + + windowTitle + " state=" + getStateDescription()); + return state == WindowState::idle; + } + const auto bounds = mixerWindow->getBounds(); mixerWindow->setVisible(false); + setState(WindowState::readyHidden, "hide bounds=" + describeBounds(bounds)); if (closedCallback) closedCallback(bounds); @@ -171,18 +315,353 @@ bool MixerWindowManager::hide() bool MixerWindowManager::isOpen() const { - return mixerWindow != nullptr && mixerWindow->isVisible(); + return mixerWindow != nullptr && mixerWindow->isVisible() && state == WindowState::visible; +} + +bool MixerWindowManager::isFrontendReady() const +{ + if (mixerWindow != nullptr) + if (auto* hosted = mixerWindow->getHostedComponent()) + return hosted->hasFrontendStartupSucceeded(); + + return false; +} + +juce::String MixerWindowManager::getFrontendStartupStateDescription() const +{ + if (mixerWindow != nullptr) + if (auto* hosted = mixerWindow->getHostedComponent()) + return hosted->getFrontendStartupStateDescription(); + + if (closingWindow != nullptr) + if (auto* hosted = closingWindow->getHostedComponent()) + return hosted->getFrontendStartupStateDescription(); + + return "not-created"; +} + +juce::String MixerWindowManager::getStateDescription() const +{ + return stateToString(state); +} + +const char* MixerWindowManager::stateToString(WindowState stateIn) noexcept +{ + switch (stateIn) + { + case WindowState::idle: return "idle"; + case WindowState::creating: return "creating"; + case WindowState::readyHidden: return "readyHidden"; + case WindowState::visible: return "visible"; + case WindowState::closing: return "closing"; + case WindowState::retired: return "retired"; + } + + return "unknown"; +} + +bool MixerWindowManager::isGlobalCloseInProgress() noexcept +{ + return globalCloseDepth > 0; +} + +bool MixerWindowManager::isGlobalCreateInProgress() noexcept +{ + return globalCreateDepth > 0; +} + +bool MixerWindowManager::isGlobalLifecycleBusy() noexcept +{ + return isGlobalCloseInProgress() || isGlobalCreateInProgress(); } -void MixerWindowManager::handleWindowClosed() +void MixerWindowManager::addPendingManager(MixerWindowManager& manager) +{ + if (! managersWithPendingRequests.contains(&manager)) + managersWithPendingRequests.add(&manager); +} + +void MixerWindowManager::drainGlobalPendingRequests() +{ + if (isGlobalLifecycleBusy() || managersWithPendingRequests.isEmpty()) + return; + + while (! isGlobalLifecycleBusy() && ! managersWithPendingRequests.isEmpty()) + { + auto* manager = managersWithPendingRequests.getFirst(); + managersWithPendingRequests.remove(0); + + if (manager != nullptr) + manager->runPendingRequest(); + } +} + +void MixerWindowManager::beginGlobalCreateSlot(const juce::String& title) +{ + ++globalCreateDepth; + juce::Logger::writeToLog("Secondary window global create depth: " + + juce::String(globalCreateDepth) + + " after creating " + title); + + juce::Timer::callAfterDelay(createSettleDelayMs, [title]() + { + releaseGlobalCreateSlot(title); + }); +} + +void MixerWindowManager::releaseGlobalCreateSlot(const juce::String& title) +{ + globalCreateDepth = juce::jmax(0, globalCreateDepth - 1); + juce::Logger::writeToLog("Secondary window global create depth: " + + juce::String(globalCreateDepth) + + " after settling " + title); + drainGlobalPendingRequests(); +} + +bool MixerWindowManager::ensureMessageThread(const char* action) const +{ + if (juce::MessageManager::getInstance()->isThisTheMessageThread()) + return true; + + juce::Logger::writeToLog("Secondary window " + juce::String(action) + + " rejected off message thread: " + windowTitle); + jassertfalse; + return false; +} + +bool MixerWindowManager::createWindow(const juce::Rectangle<int>& targetBounds, bool visible) +{ + if (! componentFactory) + { + setState(WindowState::idle, "component factory missing"); + return false; + } + + beginGlobalCreateSlot(windowTitle); + auto content = componentFactory(); + if (content == nullptr) + { + releaseGlobalCreateSlot(windowTitle); + setState(WindowState::idle, "component factory returned null"); + return false; + } + + mixerWindow = std::make_unique<MixerWindow>(*this, std::move(content)); + mixerWindow->setBounds(targetBounds); + mixerWindow->setVisible(visible); + + if (visible) + { + mixerWindow->toFront(true); + mixerWindow->requestHostedBrowserFocus(); + } + + setState(visible ? WindowState::visible : WindowState::readyHidden, + juce::String(visible ? "created visible bounds=" : "created hidden bounds=") + describeBounds(targetBounds)); + + if (visible) + scheduleStartupNudge(); + + return true; +} + +void MixerWindowManager::scheduleStartupNudge() +{ + if (mixerWindow == nullptr) + return; + + juce::Component::SafePointer<juce::DocumentWindow> safeWindow(mixerWindow.get()); + juce::Timer::callAfterDelay(startupNudgeDelayMs, [safeWindow]() + { + if (safeWindow != nullptr) + { + const auto boundsNow = safeWindow->getBounds(); + safeWindow->setBounds(boundsNow.withWidth(boundsNow.getWidth() + 1)); + safeWindow->setBounds(boundsNow); + } + }); +} + +void MixerWindowManager::beginClose(bool notifyClosed) { if (mixerWindow == nullptr) return; const auto bounds = mixerWindow->getBounds(); + setState(WindowState::closing, "close begin bounds=" + describeBounds(bounds)); + + if (! countedGlobalClose) + { + countedGlobalClose = true; + ++globalCloseDepth; + juce::Logger::writeToLog("Secondary window global close depth: " + + juce::String(globalCloseDepth) + + " after closing " + windowTitle); + } + + if (closeStartedMs == 0) + closeStartedMs = juce::Time::currentTimeMillis(); + + // JUCE 9.0.1 removes a destroyed WebView2 from its construction queue and + // disconnects WKWebView delegates during teardown. Retire the native window + // first, then destroy it after the cross-window settle period so user-driven + // close/reopen bursts remain ordered without retaining one browser forever for + // every MIDI clip or built-in plug-in session. mixerWindow->setVisible(false); - mixerWindow = nullptr; + closingWindow = std::move(mixerWindow); + setState(WindowState::retired, "close retired bounds=" + describeBounds(bounds)); - if (closedCallback) + if (notifyClosed && closedCallback) closedCallback(bounds); + + closePendingUntilStartupSettles = false; + startTimer(closeDestroyDelayMs); +} + +void MixerWindowManager::finishClose() +{ + if (closingWindow != nullptr) + { + if (auto* hosted = closingWindow->getHostedComponent()) + { + const auto elapsedMs = juce::Time::currentTimeMillis() - closeStartedMs; + if (! hosted->hasFrontendStartupReachedTerminalState() && elapsedMs < closeStartupMaxWaitMs) + { + juce::Logger::writeToLog("Secondary retired window destruction delayed until frontend startup settles: " + + windowTitle + + " startupState=" + hosted->getFrontendStartupStateDescription() + + " elapsedMs=" + juce::String(elapsedMs)); + startTimer(closeReadinessPollMs); + return; + } + + hosted->prepareForSecondaryWindowClose(); + } + + juce::Logger::writeToLog("Secondary retired window destroyed: " + windowTitle); + closingWindow->setVisible(false); + closingWindow = nullptr; + } + + closeStartedMs = 0; + stopTimer(); + setState(WindowState::idle, "close complete"); + releaseGlobalCloseSlot(); + drainGlobalPendingRequests(); +} + +void MixerWindowManager::releaseGlobalCloseSlot() +{ + if (! countedGlobalClose) + return; + + countedGlobalClose = false; + globalCloseDepth = juce::jmax(0, globalCloseDepth - 1); + juce::Logger::writeToLog("Secondary window global close depth: " + + juce::String(globalCloseDepth) + + " after destroying " + windowTitle); +} + +void MixerWindowManager::setState(WindowState nextState, const juce::String& reason) +{ + if (state == nextState && reason.isEmpty()) + return; + + juce::Logger::writeToLog("Secondary window state: " + windowTitle + + " " + juce::String(stateToString(state)) + + " -> " + juce::String(stateToString(nextState)) + + (reason.isNotEmpty() ? " (" + reason + ")" : juce::String())); + state = nextState; +} + +void MixerWindowManager::queuePendingRequest(PendingRequest::Type type, const juce::Rectangle<int>& bounds) +{ + if (type == PendingRequest::Type::focus) + { + if (pendingRequest.type == PendingRequest::Type::prewarm) + { + pendingRequest.type = PendingRequest::Type::open; + addPendingManager(*this); + juce::Logger::writeToLog("Secondary window pending prewarm upgraded to open: " + windowTitle + + (pendingRequest.bounds.isEmpty() ? juce::String() : " bounds=" + describeBounds(pendingRequest.bounds))); + return; + } + + if (pendingRequest.type == PendingRequest::Type::open) + { + addPendingManager(*this); + return; + } + } + + pendingRequest.type = type; + pendingRequest.bounds = bounds; + addPendingManager(*this); + + juce::String typeName = "none"; + if (type == PendingRequest::Type::open) + typeName = "open"; + else if (type == PendingRequest::Type::prewarm) + typeName = "prewarm"; + else if (type == PendingRequest::Type::focus) + typeName = "focus"; + + juce::Logger::writeToLog("Secondary window request queued: " + windowTitle + + " request=" + typeName + + " state=" + getStateDescription() + + (bounds.isEmpty() ? juce::String() : " bounds=" + describeBounds(bounds))); +} + +void MixerWindowManager::runPendingRequest() +{ + const auto pending = pendingRequest; + pendingRequest = {}; + + if (pending.type == PendingRequest::Type::none) + return; + + juce::Logger::writeToLog("Secondary window running queued request: " + windowTitle + + " state=" + getStateDescription()); + + if (pending.type == PendingRequest::Type::open) + open(pending.bounds); + else if (pending.type == PendingRequest::Type::prewarm) + prewarm(pending.bounds); + else if (pending.type == PendingRequest::Type::focus) + focus(); +} + +void MixerWindowManager::timerCallback() +{ + if (closePendingUntilStartupSettles) + { + if (mixerWindow == nullptr) + { + closePendingUntilStartupSettles = false; + finishClose(); + return; + } + + const auto elapsedMs = juce::Time::currentTimeMillis() - closeStartedMs; + if (auto* hosted = mixerWindow->getHostedComponent()) + { + if (! hosted->hasFrontendStartupReachedTerminalState() && elapsedMs < closeStartupMaxWaitMs) + { + juce::Logger::writeToLog("Secondary window close waiting for frontend startup to settle: " + + windowTitle + + " startupState=" + hosted->getFrontendStartupStateDescription() + + " elapsedMs=" + juce::String(elapsedMs)); + startTimer(closeReadinessPollMs); + return; + } + } + + juce::Logger::writeToLog("Secondary window pending close now entering teardown: " + windowTitle + + " elapsedMs=" + juce::String(elapsedMs)); + closePendingUntilStartupSettles = false; + beginClose(true); + return; + } + + finishClose(); } diff --git a/Source/MixerWindowManager.h b/Source/MixerWindowManager.h index 6455150..b946e74 100644 --- a/Source/MixerWindowManager.h +++ b/Source/MixerWindowManager.h @@ -6,7 +6,7 @@ class MainComponent; -class MixerWindowManager +class MixerWindowManager : private juce::Timer { public: using ComponentFactory = std::function<std::unique_ptr<MainComponent>()>; @@ -26,11 +26,55 @@ class MixerWindowManager bool hide(); bool close(); bool isOpen() const; + bool isFrontendReady() const; + juce::String getFrontendStartupStateDescription() const; + juce::String getStateDescription() const; private: class MixerWindow; - void handleWindowClosed(); + enum class WindowState + { + idle, + creating, + readyHidden, + visible, + closing, + retired + }; + + struct PendingRequest + { + enum class Type + { + none, + open, + prewarm, + focus + }; + + Type type = Type::none; + juce::Rectangle<int> bounds; + }; + + static const char* stateToString(WindowState state) noexcept; + static bool isGlobalCloseInProgress() noexcept; + static bool isGlobalCreateInProgress() noexcept; + static bool isGlobalLifecycleBusy() noexcept; + static void addPendingManager(MixerWindowManager& manager); + static void drainGlobalPendingRequests(); + static void beginGlobalCreateSlot(const juce::String& title); + static void releaseGlobalCreateSlot(const juce::String& title); + bool ensureMessageThread(const char* action) const; + bool createWindow(const juce::Rectangle<int>& targetBounds, bool visible); + void scheduleStartupNudge(); + void beginClose(bool notifyClosed); + void finishClose(); + void releaseGlobalCloseSlot(); + void setState(WindowState nextState, const juce::String& reason = {}); + void queuePendingRequest(PendingRequest::Type type, const juce::Rectangle<int>& bounds = {}); + void runPendingRequest(); + void timerCallback() override; ComponentFactory componentFactory; ClosedCallback closedCallback; @@ -38,7 +82,22 @@ class MixerWindowManager juce::Rectangle<int> defaultBounds; int minWidth = 900; int minHeight = 380; + WindowState state = WindowState::idle; + PendingRequest pendingRequest; std::unique_ptr<MixerWindow> mixerWindow; + std::unique_ptr<MixerWindow> closingWindow; + bool countedGlobalClose = false; + bool closePendingUntilStartupSettles = false; + juce::int64 closeStartedMs = 0; + + static constexpr int closeDestroyDelayMs = 1500; + static constexpr int closeReadinessPollMs = 250; + static constexpr int closeStartupMaxWaitMs = 8000; + static constexpr int createSettleDelayMs = 1200; + static constexpr int startupNudgeDelayMs = 600; + static int globalCloseDepth; + static int globalCreateDepth; + static juce::Array<MixerWindowManager*> managersWithPendingRequests; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MixerWindowManager) }; diff --git a/Source/NAMCabPresentation.cpp b/Source/NAMCabPresentation.cpp new file mode 100644 index 0000000..6d7202e --- /dev/null +++ b/Source/NAMCabPresentation.cpp @@ -0,0 +1,1945 @@ +#include "NAMCabPresentation.h" + +#include <algorithm> +#include <cmath> +#include <limits> + +namespace +{ +constexpr std::array<float, 8> roomTapMilliseconds { + 3.1f, 5.7f, 8.9f, 12.7f, + 17.3f, 22.9f, 28.7f, 36.1f +}; + +constexpr std::array<float, 8> roomCrossDeltaMilliseconds { + 0.7f, 1.3f, 0.9f, 1.7f, + 1.1f, 2.1f, 1.5f, 2.7f +}; + +constexpr std::array<float, 8> roomDirectTapGainL { + 0.46f, -0.34f, 0.28f, -0.23f, + 0.19f, -0.15f, 0.12f, -0.09f +}; + +constexpr std::array<float, 8> roomCrossTapGainL { + 0.17f, 0.14f, -0.13f, 0.11f, + -0.10f, 0.085f, -0.071f, 0.058f +}; + +// A real room does not present mirrored image-source paths to the two ears. +// These fixed sub-millisecond offsets and modest gain/polarity differences +// create a stable stereo field from a mono close cabinet without an LFO or a +// random result on recall. The unchanged close signal remains the centre +// anchor; only the parallel wet field is asymmetric. +constexpr std::array<float, 8> roomRightDirectDeltaMilliseconds { + 0.43f, -0.29f, 0.61f, -0.37f, + 0.83f, -0.47f, 1.07f, -0.59f +}; + +constexpr std::array<float, 8> roomRightCrossExtraMilliseconds { + 0.31f, -0.21f, 0.47f, -0.33f, + 0.69f, -0.41f, 0.91f, -0.55f +}; + +constexpr std::array<float, 8> roomDirectTapGainR { + 0.43f, -0.31f, 0.255f, -0.205f, + -0.175f, -0.137f, 0.108f, 0.076f +}; + +constexpr std::array<float, 8> roomCrossTapGainR { + -0.155f, 0.128f, -0.118f, 0.102f, + -0.091f, -0.078f, -0.065f, 0.051f +}; + +// Mutually incommensurate short delays make a compact four-line Householder +// FDN dense quickly without modulation. The feedback range below gives an +// approximate 180..650 ms RT60 around the mean line length. +constexpr std::array<float, 4> lateRoomDelayMilliseconds { + 29.7f, 34.9f, 41.3f, 47.9f +}; + +constexpr float butterworthQ = 0.7071067811865475f; +// 2^(6 cents / 1200) - 1. Kept as a literal so the realtime drift segment +// setup never evaluates pow(). +constexpr float maximumDoublerDelaySlope = 0.0034717485f; +constexpr float selfTestTolerance = 2.0e-6f; +// Product references motivate a centred low-frequency presentation, but no +// ITU recommendation mandates a numerical crossover target. These explicit +// engineering thresholds are therefore reported rather than presented as a +// psychoacoustic standard. +constexpr float lowFrequencySideToMidLimitDb = -18.0f; +constexpr float highFrequencySideRmsMinimum = 1.0e-4f; +// This is a deterministic post-arrival automation residual, not an audible +// quality threshold. The audible linear wet laws intentionally produce more +// signal during a 20 ms move than the retired squared laws; -74 dBFS remains +// a conservative ceiling for a one-sample residual step. +constexpr float automationDezipperErrorLimit = 2.0e-4f; +constexpr float automationOutputPeakLimit = 4.0f; + +float maximumAbsoluteDifference(const juce::AudioBuffer<float>& first, + const juce::AudioBuffer<float>& second) noexcept +{ + const int channels = juce::jmin(first.getNumChannels(), second.getNumChannels()); + const int samples = juce::jmin(first.getNumSamples(), second.getNumSamples()); + float maximumDifference = 0.0f; + for (int channel = 0; channel < channels; ++channel) + { + const auto* const firstSamples = first.getReadPointer(channel); + const auto* const secondSamples = second.getReadPointer(channel); + for (int sample = 0; sample < samples; ++sample) + { + maximumDifference = juce::jmax( + maximumDifference, + std::abs(firstSamples[sample] - secondSamples[sample])); + } + } + return maximumDifference; +} + +void fillDeterministicTestSignal(juce::AudioBuffer<float>& buffer) noexcept +{ + std::uint32_t randomState = 0x4d595df4u; + const int channels = buffer.getNumChannels(); + const int samples = buffer.getNumSamples(); + for (int sample = 0; sample < samples; ++sample) + { + randomState ^= randomState << 13u; + randomState ^= randomState >> 17u; + randomState ^= randomState << 5u; + const float noise = static_cast<float>(randomState & 0x00ffffffu) + * (2.0f / 16777215.0f) - 1.0f; + const float phase = static_cast<float>(sample) + * (2.0f * juce::MathConstants<float>::pi * 113.0f / 48000.0f); + const float value = std::sin(phase) * 0.24f + noise * 0.035f; + for (int channel = 0; channel < channels; ++channel) + buffer.setSample(channel, sample, value); + } +} + +void processInPartitions(NAMCabPresentation& processor, + juce::AudioBuffer<float>& buffer, + int partitionSize) noexcept +{ + const int safePartitionSize = juce::jmax(1, partitionSize); + int offset = 0; + while (offset < buffer.getNumSamples()) + { + const int blockSamples = juce::jmin( + safePartitionSize, + buffer.getNumSamples() - offset); + std::array<float*, 2> channelPointers { + buffer.getWritePointer(0, offset), + buffer.getNumChannels() >= 2 + ? buffer.getWritePointer(1, offset) + : nullptr + }; + juce::AudioBuffer<float> block( + channelPointers.data(), + buffer.getNumChannels(), + blockSamples); + processor.process(block); + offset += blockSamples; + } +} +} + +void NAMCabPresentation::Biquad::configureLowPass(double sampleRate, + float frequencyHz, + float q) noexcept +{ + const float safeSampleRate = static_cast<float>(juce::jmax(1.0, sampleRate)); + const float safeFrequency = juce::jlimit(5.0f, safeSampleRate * 0.45f, frequencyHz); + const float safeQ = juce::jmax(0.05f, q); + const float omega = 2.0f * juce::MathConstants<float>::pi + * safeFrequency / safeSampleRate; + const float cosine = std::cos(omega); + const float sine = std::sin(omega); + const float alpha = sine / (2.0f * safeQ); + const float inverseA0 = 1.0f / (1.0f + alpha); + + b0 = (1.0f - cosine) * 0.5f * inverseA0; + b1 = (1.0f - cosine) * inverseA0; + b2 = b0; + a1 = -2.0f * cosine * inverseA0; + a2 = (1.0f - alpha) * inverseA0; +} + +void NAMCabPresentation::Biquad::configureHighPass(double sampleRate, + float frequencyHz, + float q) noexcept +{ + const float safeSampleRate = static_cast<float>(juce::jmax(1.0, sampleRate)); + const float safeFrequency = juce::jlimit(5.0f, safeSampleRate * 0.45f, frequencyHz); + const float safeQ = juce::jmax(0.05f, q); + const float omega = 2.0f * juce::MathConstants<float>::pi + * safeFrequency / safeSampleRate; + const float cosine = std::cos(omega); + const float sine = std::sin(omega); + const float alpha = sine / (2.0f * safeQ); + const float inverseA0 = 1.0f / (1.0f + alpha); + + b0 = (1.0f + cosine) * 0.5f * inverseA0; + b1 = -(1.0f + cosine) * inverseA0; + b2 = b0; + a1 = -2.0f * cosine * inverseA0; + a2 = (1.0f - alpha) * inverseA0; +} + +void NAMCabPresentation::Biquad::reset() noexcept +{ + z1 = 0.0f; + z2 = 0.0f; +} + +float NAMCabPresentation::Biquad::processSample(float sample) noexcept +{ + if (! std::isfinite(sample) + || ! std::isfinite(z1) + || ! std::isfinite(z2)) + { + reset(); + return 0.0f; + } + const float output = b0 * sample + z1; + const float nextZ1 = b1 * sample - a1 * output + z2; + const float nextZ2 = b2 * sample - a2 * output; + if (! std::isfinite(output) + || ! std::isfinite(nextZ1) + || ! std::isfinite(nextZ2)) + { + reset(); + return 0.0f; + } + z1 = nextZ1; + z2 = nextZ2; + return output; +} + +float NAMCabPresentation::clampUnit(float value) noexcept +{ + return std::isfinite(value) ? juce::jlimit(0.0f, 1.0f, value) : 0.0f; +} + +float NAMCabPresentation::nextRandomSigned(std::uint32_t& state) noexcept +{ + if (state == 0u) + state = 0x6d2b79f5u; + state ^= state << 13u; + state ^= state >> 17u; + state ^= state << 5u; + return static_cast<float>(state & 0x00ffffffu) + * (2.0f / 16777215.0f) - 1.0f; +} + +float NAMCabPresentation::smootherStep(float value) noexcept +{ + const float x = juce::jlimit(0.0f, 1.0f, value); + return x * x * x * (x * (x * 6.0f - 15.0f) + 10.0f); +} + +float NAMCabPresentation::raisedCosine(float value) noexcept +{ + const float x = juce::jlimit(0.0f, 1.0f, value); + return 0.5f - 0.5f * std::cos(juce::MathConstants<float>::pi * x); +} + +float NAMCabPresentation::mapRoomGain(float amount) noexcept +{ + // Linear amplitude mapping keeps the useful lower half of the control + // audible. The former squared law attenuated the factory 0.22 setting by + // more than 31 dB before the field's own normalisation. + return 0.85f * clampUnit(amount); +} + +float NAMCabPresentation::mapDoublerGain(float amount) noexcept +{ + // The doubler is a parallel voice against a unity direct signal. A linear + // law gives the default 0.12 setting an audible but still subordinate + // contribution while retaining headroom at the top of the control. + return 0.90f * clampUnit(amount); +} + +float NAMCabPresentation::clampDoublerDelayMs(float delayMs) noexcept +{ + return std::isfinite(delayMs) ? juce::jlimit(3.0f, 20.0f, delayMs) : 4.5f; +} + +void NAMCabPresentation::prepare(double sampleRate, int maximumBlockSize) +{ + prepared = false; + currentSampleRate = std::isfinite(sampleRate) + ? juce::jmax(8000.0, sampleRate) + : 48000.0; + preparedMaximumBlockSize = juce::jmax(1, maximumBlockSize); + + const int roomRingSamples = juce::jmax( + 16, + static_cast<int>(std::ceil(currentSampleRate * 0.064)) + 8); + roomRingL.assign(static_cast<std::size_t>(roomRingSamples), 0.0f); + roomRingR.assign(static_cast<std::size_t>(roomRingSamples), 0.0f); + + for (std::size_t line = 0; line < lateRoomLineCount; ++line) + { + const int lineSamples = juce::jmax( + 8, + juce::roundToInt( + lateRoomDelayMilliseconds[line] + * 0.001f + * static_cast<float>(currentSampleRate))); + lateRoomRings[line].assign( + static_cast<std::size_t>(lineSamples), + 0.0f); + } + + const int doublerRingSamples = juce::jmax( + 16, + static_cast<int>(std::ceil(currentSampleRate * 0.050)) + 8); + doublerRingL.assign(static_cast<std::size_t>(doublerRingSamples), 0.0f); + doublerRingR.assign(static_cast<std::size_t>(doublerRingSamples), 0.0f); + + for (std::size_t tap = 0; tap < roomTapCount; ++tap) + { + roomDirectTapSamplesL[tap] = juce::jlimit( + 1, + roomRingSamples - 4, + juce::roundToInt( + roomTapMilliseconds[tap] + * 0.001f + * static_cast<float>(currentSampleRate))); + roomDirectTapSamplesR[tap] = juce::jlimit( + 1, + roomRingSamples - 4, + juce::roundToInt( + (roomTapMilliseconds[tap] + + roomRightDirectDeltaMilliseconds[tap]) + * 0.001f + * static_cast<float>(currentSampleRate))); + roomCrossTapSamplesL[tap] = juce::jlimit( + 1, + roomRingSamples - 4, + juce::roundToInt( + (roomTapMilliseconds[tap] + roomCrossDeltaMilliseconds[tap]) + * 0.001f + * static_cast<float>(currentSampleRate))); + roomCrossTapSamplesR[tap] = juce::jlimit( + 1, + roomRingSamples - 4, + juce::roundToInt( + (roomTapMilliseconds[tap] + + roomCrossDeltaMilliseconds[tap] + + roomRightCrossExtraMilliseconds[tap]) + * 0.001f + * static_cast<float>(currentSampleRate))); + } + + smoothingCoefficient = 1.0f - std::exp( + -1.0f + / static_cast<float>( + currentSampleRate * static_cast<double>(parameterSmoothingSeconds))); + fastEnvelopeRelease = std::exp( + -1.0f / static_cast<float>(currentSampleRate * 0.008)); + slowEnvelopeCoefficient = 1.0f - std::exp( + -1.0f / static_cast<float>(currentSampleRate * 0.080)); + transientDuckReleaseCoefficient = 1.0f - std::exp( + -1.0f / static_cast<float>(currentSampleRate * 0.060)); + lateRoomDampingCoefficient = 1.0f - std::exp( + -2.0f * juce::MathConstants<float>::pi * 5600.0f + / static_cast<float>(currentSampleRate)); + delayMorphLength = juce::jmax( + 1, + juce::roundToInt( + static_cast<float>(currentSampleRate) + * delayMorphSeconds)); + + configureFilters(); + prepared = true; + reset(); + resetDiagnostics(); +} + +void NAMCabPresentation::configureFilters() noexcept +{ + roomWetHighPassL.configureHighPass(currentSampleRate, 120.0f, butterworthQ); + roomWetHighPassR.configureHighPass(currentSampleRate, 120.0f, butterworthQ); + roomWetLowPassL.configureLowPass(currentSampleRate, 8500.0f, butterworthQ); + roomWetLowPassR.configureLowPass(currentSampleRate, 8500.0f, butterworthQ); + for (auto& filter : roomSideHighPass) + filter.configureHighPass(currentSampleRate, 170.0f, butterworthQ); + + doublerWetHighPassL.configureHighPass(currentSampleRate, 140.0f, butterworthQ); + doublerWetHighPassR.configureHighPass(currentSampleRate, 140.0f, butterworthQ); + doublerWetLowPassL.configureLowPass(currentSampleRate, 9000.0f, butterworthQ); + doublerWetLowPassR.configureLowPass(currentSampleRate, 9000.0f, butterworthQ); + for (auto& filter : doublerSideHighPass) + filter.configureHighPass(currentSampleRate, 170.0f, butterworthQ); +} + +void NAMCabPresentation::resetRoomRuntimeState(bool clearStorage) noexcept +{ + if (clearStorage) + { + std::fill(roomRingL.begin(), roomRingL.end(), 0.0f); + std::fill(roomRingR.begin(), roomRingR.end(), 0.0f); + for (auto& ring : lateRoomRings) + std::fill(ring.begin(), ring.end(), 0.0f); + } + roomWriteIndex = 0; + validRoomHistorySamples = 0; + lateRoomWriteIndices.fill(0); + lateRoomValidSamples.fill(0); + lateRoomDampingStates.fill(0.0f); + roomWetHighPassL.reset(); + roomWetHighPassR.reset(); + roomWetLowPassL.reset(); + roomWetLowPassR.reset(); + for (auto& filter : roomSideHighPass) + filter.reset(); + roomDormant = true; +} + +void NAMCabPresentation::resetDoublerRuntimeState(bool clearStorage) noexcept +{ + if (clearStorage) + { + std::fill(doublerRingL.begin(), doublerRingL.end(), 0.0f); + std::fill(doublerRingR.begin(), doublerRingR.end(), 0.0f); + } + doublerWriteIndex = 0; + validDoublerHistorySamples = 0; + doublerWetHighPassL.reset(); + doublerWetHighPassR.reset(); + doublerWetLowPassL.reset(); + doublerWetLowPassR.reset(); + for (auto& filter : doublerSideHighPass) + filter.reset(); + doublerDriftL = {}; + doublerDriftR = {}; + doublerDriftL.randomState = 0x9e3779b9u; + doublerDriftR.randomState = 0x7f4a7c15u; + transientFastEnvelope = 0.0f; + transientSlowEnvelope = 0.0f; + roomTransientDuck = 1.0f; + doublerTransientDuck = 1.0f; + requestedDelaySpread = clampUnit( + targetDoublerSpread.load(std::memory_order_relaxed)); + activeDelaySpread = requestedDelaySpread; + morphTargetDelaySpread = requestedDelaySpread; + requestedDoublerDelayMs = clampDoublerDelayMs( + targetDoublerDelayMs.load(std::memory_order_relaxed)); + activeDoublerDelayMs = requestedDoublerDelayMs; + morphTargetDoublerDelayMs = requestedDoublerDelayMs; + delayMorphPosition = 0; + delayMorphActive = false; + doublerDormant = true; +} + +void NAMCabPresentation::invalidateRoomHistory() noexcept +{ + resetRoomRuntimeState(false); +} + +void NAMCabPresentation::invalidateDoublerHistory() noexcept +{ + resetDoublerRuntimeState(false); +} + +void NAMCabPresentation::reset() noexcept +{ + const float roomAmount = clampUnit( + targetRoomAmount.load(std::memory_order_relaxed)); + const float doublerAmount = clampUnit( + targetDoublerMix.load(std::memory_order_relaxed)); + currentRoomGain = mapRoomGain(roomAmount); + currentRoomWidth = clampUnit( + targetRoomWidth.load(std::memory_order_relaxed)); + currentLateRoomFeedback = 0.23f + 0.43f * roomAmount; + currentDoublerGain = mapDoublerGain(doublerAmount); + currentDoublerSpread = clampUnit( + targetDoublerSpread.load(std::memory_order_relaxed)); + resetRoomRuntimeState(true); + resetDoublerRuntimeState(true); +} + +void NAMCabPresentation::setParameters(const Parameters& newParameters) noexcept +{ + setRoomAmount(newParameters.roomAmount); + setRoomWidth(newParameters.roomWidth); + setRoomInputSendEnabled(newParameters.roomInputSendEnabled); + setDoublerMix(newParameters.doublerMix); + setDoublerSpread(newParameters.doublerSpread); + setDoublerDelayMs(newParameters.doublerDelayMs); +} + +NAMCabPresentation::Parameters NAMCabPresentation::getParameters() const noexcept +{ + Parameters result; + result.roomAmount = targetRoomAmount.load(std::memory_order_relaxed); + result.roomWidth = targetRoomWidth.load(std::memory_order_relaxed); + result.roomInputSendEnabled = + targetRoomInputSendEnabled.load(std::memory_order_relaxed); + result.doublerMix = targetDoublerMix.load(std::memory_order_relaxed); + result.doublerSpread = targetDoublerSpread.load(std::memory_order_relaxed); + result.doublerDelayMs = + targetDoublerDelayMs.load(std::memory_order_relaxed); + return result; +} + +void NAMCabPresentation::setRoomAmount(float amount) noexcept +{ + targetRoomAmount.store(clampUnit(amount), std::memory_order_relaxed); +} + +void NAMCabPresentation::setRoomWidth(float width) noexcept +{ + targetRoomWidth.store(clampUnit(width), std::memory_order_relaxed); +} + +void NAMCabPresentation::setRoomInputSendEnabled(bool enabled) noexcept +{ + targetRoomInputSendEnabled.store(enabled, std::memory_order_relaxed); +} + +void NAMCabPresentation::setDoublerMix(float mix) noexcept +{ + targetDoublerMix.store(clampUnit(mix), std::memory_order_relaxed); +} + +void NAMCabPresentation::setDoublerSpread(float spread) noexcept +{ + targetDoublerSpread.store(clampUnit(spread), std::memory_order_relaxed); +} + +void NAMCabPresentation::setDoublerDelayMs(float delayMs) noexcept +{ + targetDoublerDelayMs.store( + clampDoublerDelayMs(delayMs), + std::memory_order_relaxed); +} + +float NAMCabPresentation::readRoomSample(const std::vector<float>& ring, + int delaySamples) const noexcept +{ + if (ring.empty() + || delaySamples <= 0 + || delaySamples > validRoomHistorySamples) + { + return 0.0f; + } + + int readIndex = roomWriteIndex - delaySamples; + if (readIndex < 0) + readIndex += static_cast<int>(ring.size()); + return ring[static_cast<std::size_t>(readIndex)]; +} + +float NAMCabPresentation::readDoublerSample(const std::vector<float>& ring, + float delaySamples) const noexcept +{ + if (ring.empty() || ! std::isfinite(delaySamples)) + return 0.0f; + + const float safeDelay = juce::jlimit( + 3.0f, + static_cast<float>(ring.size() - 4u), + delaySamples); + if (static_cast<int>(std::ceil(safeDelay)) + 2 + > validDoublerHistorySamples) + { + return 0.0f; + } + + float readPosition = static_cast<float>(doublerWriteIndex) - safeDelay; + if (readPosition < 0.0f) + readPosition += static_cast<float>(ring.size()); + const int centreIndex = static_cast<int>(std::floor(readPosition)); + const float fraction = readPosition - static_cast<float>(centreIndex); + const int ringSize = static_cast<int>(ring.size()); + const auto sampleAt = [&ring, ringSize] (int index) noexcept + { + while (index < 0) + index += ringSize; + while (index >= ringSize) + index -= ringSize; + return ring[static_cast<std::size_t>(index)]; + }; + + const float y0 = sampleAt(centreIndex - 1); + const float y1 = sampleAt(centreIndex); + const float y2 = sampleAt(centreIndex + 1); + const float y3 = sampleAt(centreIndex + 2); + const float fractionSquared = fraction * fraction; + const float fractionCubed = fractionSquared * fraction; + return 0.5f + * (2.0f * y1 + + (-y0 + y2) * fraction + + (2.0f * y0 - 5.0f * y1 + 4.0f * y2 - y3) * fractionSquared + + (-y0 + 3.0f * y1 - 3.0f * y2 + y3) * fractionCubed); +} + +void NAMCabPresentation::advanceDoublerDrift(DoublerDriftState& state, + float spread) noexcept +{ + if (state.segmentLength <= 0 + || state.segmentPosition >= state.segmentLength) + { + state.offsetStartSamples = state.currentOffsetSamples; + state.levelStart = state.currentLevel; + const float depthMilliseconds = 0.08f + 0.30f * clampUnit(spread); + state.offsetTargetSamples = nextRandomSigned(state.randomState) + * depthMilliseconds + * 0.001f + * static_cast<float>(currentSampleRate); + const float targetLevelDb = nextRandomSigned(state.randomState) * 0.6f; + state.levelTarget = juce::Decibels::decibelsToGain(targetLevelDb); + + const float randomDuration = 0.18f + + (nextRandomSigned(state.randomState) * 0.5f + 0.5f) * 0.42f; + int durationSamples = juce::jmax( + 1, + juce::roundToInt( + randomDuration + * static_cast<float>(currentSampleRate))); + const float offsetChange = std::abs( + state.offsetTargetSamples - state.offsetStartSamples); + const int slopeLimitedSamples = juce::jmax( + 1, + static_cast<int>(std::ceil( + 1.875f * offsetChange + / maximumDoublerDelaySlope))); + durationSamples = juce::jmax(durationSamples, slopeLimitedSamples); + state.segmentLength = durationSamples; + state.segmentPosition = 0; + } + + const float progress = static_cast<float>(state.segmentPosition + 1) + / static_cast<float>(juce::jmax(1, state.segmentLength)); + const float shapedProgress = smootherStep(progress); + state.currentOffsetSamples = state.offsetStartSamples + + (state.offsetTargetSamples - state.offsetStartSamples) * shapedProgress; + state.currentLevel = state.levelStart + + (state.levelTarget - state.levelStart) * shapedProgress; + ++state.segmentPosition; +} + +void NAMCabPresentation::startDoublerDelayMorph(float requestedDelayMsValue, + float requestedSpreadValue) noexcept +{ + const float safeDelayMs = clampDoublerDelayMs(requestedDelayMsValue); + const float safeSpread = clampUnit(requestedSpreadValue); + if (std::abs(safeDelayMs - activeDoublerDelayMs) <= 1.0e-4f + && std::abs(safeSpread - activeDelaySpread) <= 1.0e-5f) + { + activeDoublerDelayMs = safeDelayMs; + morphTargetDoublerDelayMs = safeDelayMs; + activeDelaySpread = safeSpread; + morphTargetDelaySpread = safeSpread; + delayMorphActive = false; + delayMorphPosition = 0; + return; + } + + morphTargetDoublerDelayMs = safeDelayMs; + morphTargetDelaySpread = safeSpread; + delayMorphPosition = 0; + delayMorphActive = true; +} + +void NAMCabPresentation::processLateRoom(float inputL, + float inputR, + float& outputL, + float& outputR) noexcept +{ + std::array<float, lateRoomLineCount> delayed {}; + for (std::size_t line = 0; line < lateRoomLineCount; ++line) + { + auto& ring = lateRoomRings[line]; + if (ring.empty()) + continue; + + const int writeIndex = lateRoomWriteIndices[line]; + const float rawDelayed = lateRoomValidSamples[line] + >= static_cast<int>(ring.size()) + ? ring[static_cast<std::size_t>(writeIndex)] + : 0.0f; + float damped = lateRoomDampingStates[line] + + (rawDelayed - lateRoomDampingStates[line]) + * lateRoomDampingCoefficient; + if (! std::isfinite(damped)) + damped = 0.0f; + lateRoomDampingStates[line] = damped; + delayed[line] = damped; + } + + // 2/N Householder feedback is orthogonal before damping. It distributes + // every arrival to all four unequal lines without growing field energy. + const float householderSum = 0.5f + * (delayed[0] + delayed[1] + delayed[2] + delayed[3]); + const std::array<float, lateRoomLineCount> injection { + inputL, + inputR, + -inputL, + -inputR + }; + for (std::size_t line = 0; line < lateRoomLineCount; ++line) + { + auto& ring = lateRoomRings[line]; + if (ring.empty()) + continue; + + float writeValue = injection[line] * lateRoomInputGain + + (householderSum - delayed[line]) * currentLateRoomFeedback; + if (! std::isfinite(writeValue)) + writeValue = 0.0f; + ring[static_cast<std::size_t>(lateRoomWriteIndices[line])] = writeValue; + ++lateRoomWriteIndices[line]; + if (lateRoomWriteIndices[line] >= static_cast<int>(ring.size())) + lateRoomWriteIndices[line] = 0; + lateRoomValidSamples[line] = juce::jmin( + static_cast<int>(ring.size()), + lateRoomValidSamples[line] + 1); + } + + outputL = (delayed[0] + delayed[1] - delayed[2] - delayed[3]) + * 0.5f + * lateRoomOutputGain; + outputR = (delayed[0] - delayed[1] + delayed[2] - delayed[3]) + * 0.5f + * lateRoomOutputGain; + if (! std::isfinite(outputL)) + outputL = 0.0f; + if (! std::isfinite(outputR)) + outputR = 0.0f; +} + +void NAMCabPresentation::process(juce::AudioBuffer<float>& buffer) noexcept +{ + juce::ScopedNoDenormals noDenormals; + const int numSamples = buffer.getNumSamples(); + const int numChannels = buffer.getNumChannels(); + if (! prepared || numSamples <= 0 || numChannels <= 0) + return; + + diagnosticProcessedBlocks.fetch_add(1u, std::memory_order_relaxed); + diagnosticProcessedSamples.fetch_add( + static_cast<std::uint32_t>(numSamples), + std::memory_order_relaxed); + if (numSamples > preparedMaximumBlockSize) + diagnosticOversizedBlocks.fetch_add(1u, std::memory_order_relaxed); + + const float roomAmount = clampUnit( + targetRoomAmount.load(std::memory_order_relaxed)); + const float roomGainTarget = mapRoomGain(roomAmount); + const float roomWidthTarget = clampUnit( + targetRoomWidth.load(std::memory_order_relaxed)); + const bool roomInputSendEnabled = + targetRoomInputSendEnabled.load(std::memory_order_relaxed); + const float lateRoomFeedbackTarget = 0.23f + 0.43f * roomAmount; + const float doublerAmount = clampUnit( + targetDoublerMix.load(std::memory_order_relaxed)); + const float doublerGainTarget = mapDoublerGain(doublerAmount); + const float doublerSpreadTarget = clampUnit( + targetDoublerSpread.load(std::memory_order_relaxed)); + const float doublerDelayMsTarget = clampDoublerDelayMs( + targetDoublerDelayMs.load(std::memory_order_relaxed)); + requestedDelaySpread = doublerSpreadTarget; + requestedDoublerDelayMs = doublerDelayMsTarget; + + constexpr float dormantThreshold = 1.0e-8f; + if (roomGainTarget <= 0.0f + && doublerGainTarget <= 0.0f + && currentRoomGain <= dormantThreshold + && currentDoublerGain <= dormantThreshold) + { + currentRoomGain = 0.0f; + currentDoublerGain = 0.0f; + currentRoomWidth = roomWidthTarget; + currentDoublerSpread = doublerSpreadTarget; + if (! roomDormant) + invalidateRoomHistory(); + if (! doublerDormant) + invalidateDoublerHistory(); + activeDelaySpread = doublerSpreadTarget; + morphTargetDelaySpread = doublerSpreadTarget; + requestedDelaySpread = doublerSpreadTarget; + activeDoublerDelayMs = doublerDelayMsTarget; + morphTargetDoublerDelayMs = doublerDelayMsTarget; + requestedDoublerDelayMs = doublerDelayMsTarget; + delayMorphActive = false; + transientFastEnvelope = 0.0f; + transientSlowEnvelope = 0.0f; + roomTransientDuck = 1.0f; + doublerTransientDuck = 1.0f; + float dryPeak = 0.0f; + std::uint32_t nonFiniteInputSamples = 0u; + const int channelsToSanitize = juce::jmin(2, numChannels); + for (int channel = 0; channel < channelsToSanitize; ++channel) + { + auto* const samples = buffer.getWritePointer(channel); + for (int sample = 0; sample < numSamples; ++sample) + { + if (! std::isfinite(samples[sample])) + { + samples[sample] = 0.0f; + ++nonFiniteInputSamples; + } + dryPeak = juce::jmax(dryPeak, std::abs(samples[sample])); + } + } + diagnosticNonFiniteInputSamples.fetch_add( + nonFiniteInputSamples, + std::memory_order_relaxed); + diagnosticZeroEffectBlocks.fetch_add(1u, std::memory_order_relaxed); + diagnosticLastDryPeak.store(dryPeak, std::memory_order_relaxed); + diagnosticLastGeneratedMidPeak.store(0.0f, std::memory_order_relaxed); + diagnosticLastGeneratedSidePeak.store(0.0f, std::memory_order_relaxed); + return; + } + + const bool roomShouldRun = roomGainTarget > 0.0f + || currentRoomGain > dormantThreshold; + const bool doublerShouldRun = doublerGainTarget > 0.0f + || currentDoublerGain > dormantThreshold; + if (roomShouldRun && roomDormant) + { + invalidateRoomHistory(); + roomDormant = false; + } + if (doublerShouldRun && doublerDormant) + { + invalidateDoublerHistory(); + doublerDormant = false; + activeDelaySpread = doublerSpreadTarget; + morphTargetDelaySpread = doublerSpreadTarget; + requestedDelaySpread = doublerSpreadTarget; + activeDoublerDelayMs = doublerDelayMsTarget; + morphTargetDoublerDelayMs = doublerDelayMsTarget; + requestedDoublerDelayMs = doublerDelayMsTarget; + } + + float dryPeak = 0.0f; + float generatedMidPeak = 0.0f; + float generatedSidePeak = 0.0f; + std::uint32_t nonFiniteInputSamples = 0u; + std::uint32_t nonFiniteWetSamples = 0u; + + for (int sample = 0; sample < numSamples; ++sample) + { + currentRoomGain += (roomGainTarget - currentRoomGain) + * smoothingCoefficient; + currentRoomWidth += (roomWidthTarget - currentRoomWidth) + * smoothingCoefficient; + currentLateRoomFeedback += + (lateRoomFeedbackTarget - currentLateRoomFeedback) + * smoothingCoefficient; + currentDoublerGain += (doublerGainTarget - currentDoublerGain) + * smoothingCoefficient; + currentDoublerSpread += (doublerSpreadTarget - currentDoublerSpread) + * smoothingCoefficient; + if (roomGainTarget <= 0.0f && currentRoomGain < dormantThreshold) + currentRoomGain = 0.0f; + if (doublerGainTarget <= 0.0f && currentDoublerGain < dormantThreshold) + currentDoublerGain = 0.0f; + + const float rawDryL = buffer.getSample(0, sample); + const float rawDryR = numChannels >= 2 + ? buffer.getSample(1, sample) + : rawDryL; + const bool directLIsFinite = std::isfinite(rawDryL); + const bool directRIsFinite = std::isfinite(rawDryR); + const float dryL = directLIsFinite ? rawDryL : 0.0f; + const float dryR = directRIsFinite ? rawDryR : 0.0f; + if (! directLIsFinite) + ++nonFiniteInputSamples; + if (numChannels >= 2 && ! directRIsFinite) + ++nonFiniteInputSamples; + dryPeak = juce::jmax( + dryPeak, + juce::jmax(std::abs(dryL), std::abs(dryR))); + + if (! std::isfinite(transientFastEnvelope) + || ! std::isfinite(transientSlowEnvelope) + || ! std::isfinite(roomTransientDuck) + || ! std::isfinite(doublerTransientDuck)) + { + transientFastEnvelope = 0.0f; + transientSlowEnvelope = 0.0f; + roomTransientDuck = 1.0f; + doublerTransientDuck = 1.0f; + ++nonFiniteWetSamples; + } + if (roomShouldRun || doublerShouldRun) + { + // A disabled room send must not let unrelated raw input duck a + // draining tail. The doubler continues to use the direct source. + const float detectorInput = roomInputSendEnabled || doublerShouldRun + ? juce::jmax(std::abs(dryL), std::abs(dryR)) + : 0.0f; + transientFastEnvelope = juce::jmax( + detectorInput, + transientFastEnvelope * fastEnvelopeRelease); + transientSlowEnvelope += (detectorInput - transientSlowEnvelope) + * slowEnvelopeCoefficient; + const float novelty = juce::jlimit( + 0.0f, + 1.0f, + (transientFastEnvelope - transientSlowEnvelope) + / (transientFastEnvelope + 1.0e-9f)); + constexpr float roomMinimumDuck = 0.70794576f; + const float roomDuckTarget = 1.0f + - (1.0f - roomMinimumDuck) * novelty; + const float doublerDuckTarget = 1.0f - 0.5f * novelty; + if (roomDuckTarget < roomTransientDuck) + roomTransientDuck = roomDuckTarget; + else + roomTransientDuck += (roomDuckTarget - roomTransientDuck) + * transientDuckReleaseCoefficient; + if (doublerDuckTarget < doublerTransientDuck) + doublerTransientDuck = doublerDuckTarget; + else + doublerTransientDuck += (doublerDuckTarget - doublerTransientDuck) + * transientDuckReleaseCoefficient; + } + + float roomMidContribution = 0.0f; + float roomSideContribution = 0.0f; + if (roomShouldRun && ! roomRingL.empty()) + { + const float roomInputL = roomInputSendEnabled ? dryL : 0.0f; + const float roomInputR = roomInputSendEnabled ? dryR : 0.0f; + roomRingL[static_cast<std::size_t>(roomWriteIndex)] = + roomInputL * roomTransientDuck; + roomRingR[static_cast<std::size_t>(roomWriteIndex)] = + roomInputR * roomTransientDuck; + float earlyL = 0.0f; + float earlyR = 0.0f; + for (std::size_t tap = 0; tap < roomTapCount; ++tap) + { + earlyL += readRoomSample(roomRingL, roomDirectTapSamplesL[tap]) + * roomDirectTapGainL[tap]; + earlyR += readRoomSample(roomRingR, roomDirectTapSamplesR[tap]) + * roomDirectTapGainR[tap]; + earlyL += readRoomSample(roomRingR, roomCrossTapSamplesL[tap]) + * roomCrossTapGainL[tap]; + earlyR += readRoomSample(roomRingL, roomCrossTapSamplesR[tap]) + * roomCrossTapGainR[tap]; + } + earlyL = roomWetLowPassL.processSample( + roomWetHighPassL.processSample(earlyL * roomFieldNormalisation)); + earlyR = roomWetLowPassR.processSample( + roomWetHighPassR.processSample(earlyR * roomFieldNormalisation)); + float lateL = 0.0f; + float lateR = 0.0f; + processLateRoom(earlyL, earlyR, lateL, lateR); + const float roomFieldL = earlyL + lateL; + const float roomFieldR = earlyR + lateR; + float roomMid = (roomFieldL + roomFieldR) * 0.5f; + float roomSide = (roomFieldL - roomFieldR) * 0.5f; + roomSide = roomSideHighPass[0].processSample(roomSide); + roomSide = roomSideHighPass[1].processSample(roomSide); + + const float width = currentRoomWidth * 1.35f; + const float widthCompensation = width > 1.0f + ? std::sqrt(2.0f / (1.0f + width * width)) + : 1.0f; + roomMidContribution = roomMid + * currentRoomGain + * roomMidScale + * widthCompensation; + roomSideContribution = roomSide + * currentRoomGain + * width + * widthCompensation; + + ++roomWriteIndex; + if (roomWriteIndex >= static_cast<int>(roomRingL.size())) + roomWriteIndex = 0; + validRoomHistorySamples = juce::jmin( + static_cast<int>(roomRingL.size()) - 1, + validRoomHistorySamples + 1); + } + + float doublerMidContribution = 0.0f; + float doublerSideContribution = 0.0f; + if (doublerShouldRun && ! doublerRingL.empty()) + { + doublerRingL[static_cast<std::size_t>(doublerWriteIndex)] = + dryL * doublerTransientDuck; + doublerRingR[static_cast<std::size_t>(doublerWriteIndex)] = + dryR * doublerTransientDuck; + + advanceDoublerDrift(doublerDriftL, currentDoublerSpread); + advanceDoublerDrift(doublerDriftR, currentDoublerSpread); + if (! delayMorphActive + && (std::abs( + requestedDoublerDelayMs - activeDoublerDelayMs) + > 1.0e-4f + || std::abs(requestedDelaySpread - activeDelaySpread) + > 1.0e-5f)) + { + startDoublerDelayMorph( + requestedDoublerDelayMs, + requestedDelaySpread); + } + + const auto voiceDelaySamples = [this] (float delayMs, + float spreadValue, + bool leftVoice, + float driftSamples) noexcept + { + const float separationMs = 3.0f * clampUnit(spreadValue); + const float voiceDelayMs = clampDoublerDelayMs( + delayMs + separationMs * (leftVoice ? -0.4f : 0.6f)); + const float minimumSamples = static_cast<float>(currentSampleRate) + * 0.003f; + const float maximumSamples = static_cast<float>(currentSampleRate) + * 0.020f; + return juce::jlimit( + minimumSamples, + maximumSamples, + voiceDelayMs * 0.001f + * static_cast<float>(currentSampleRate) + + driftSamples); + }; + + float voiceL = 0.0f; + float voiceR = 0.0f; + if (delayMorphActive) + { + const float morphProgress = static_cast<float>(delayMorphPosition + 1) + / static_cast<float>(juce::jmax(1, delayMorphLength)); + const float morphWeight = raisedCosine(morphProgress); + const float oldVoiceL = readDoublerSample( + doublerRingL, + voiceDelaySamples( + activeDoublerDelayMs, + activeDelaySpread, + true, + doublerDriftL.currentOffsetSamples)); + const float newVoiceL = readDoublerSample( + doublerRingL, + voiceDelaySamples( + morphTargetDoublerDelayMs, + morphTargetDelaySpread, + true, + doublerDriftL.currentOffsetSamples)); + const float oldVoiceR = readDoublerSample( + doublerRingR, + voiceDelaySamples( + activeDoublerDelayMs, + activeDelaySpread, + false, + doublerDriftR.currentOffsetSamples)); + const float newVoiceR = readDoublerSample( + doublerRingR, + voiceDelaySamples( + morphTargetDoublerDelayMs, + morphTargetDelaySpread, + false, + doublerDriftR.currentOffsetSamples)); + voiceL = oldVoiceL + (newVoiceL - oldVoiceL) * morphWeight; + voiceR = oldVoiceR + (newVoiceR - oldVoiceR) * morphWeight; + ++delayMorphPosition; + if (delayMorphPosition >= delayMorphLength) + { + activeDoublerDelayMs = morphTargetDoublerDelayMs; + activeDelaySpread = morphTargetDelaySpread; + delayMorphPosition = 0; + delayMorphActive = false; + } + } + else + { + voiceL = readDoublerSample( + doublerRingL, + voiceDelaySamples( + activeDoublerDelayMs, + activeDelaySpread, + true, + doublerDriftL.currentOffsetSamples)); + voiceR = readDoublerSample( + doublerRingR, + voiceDelaySamples( + activeDoublerDelayMs, + activeDelaySpread, + false, + doublerDriftR.currentOffsetSamples)); + } + voiceL *= doublerDriftL.currentLevel; + voiceR *= doublerDriftR.currentLevel; + voiceL = doublerWetLowPassL.processSample( + doublerWetHighPassL.processSample(voiceL)); + voiceR = doublerWetLowPassR.processSample( + doublerWetHighPassR.processSample(voiceR)); + const float voiceMid = (voiceL + voiceR) * 0.5f; + float voiceSide = (voiceL - voiceR) * 0.5f; + voiceSide = doublerSideHighPass[0].processSample(voiceSide); + voiceSide = doublerSideHighPass[1].processSample(voiceSide); + doublerMidContribution = voiceMid + * currentDoublerGain + * doublerMidScale; + doublerSideContribution = voiceSide + * currentDoublerGain + * currentDoublerSpread; + + ++doublerWriteIndex; + if (doublerWriteIndex >= static_cast<int>(doublerRingL.size())) + doublerWriteIndex = 0; + validDoublerHistorySamples = juce::jmin( + static_cast<int>(doublerRingL.size()) - 1, + validDoublerHistorySamples + 1); + } + + float generatedMid = roomMidContribution + doublerMidContribution; + float generatedSide = roomSideContribution + doublerSideContribution; + if (! std::isfinite(generatedMid)) + { + generatedMid = 0.0f; + ++nonFiniteWetSamples; + } + if (! std::isfinite(generatedSide)) + { + generatedSide = 0.0f; + ++nonFiniteWetSamples; + } + generatedMidPeak = juce::jmax(generatedMidPeak, std::abs(generatedMid)); + generatedSidePeak = juce::jmax(generatedSidePeak, std::abs(generatedSide)); + + if (numChannels >= 2) + { + float outputL = directLIsFinite + ? dryL + generatedMid + generatedSide + : 0.0f; + float outputR = directRIsFinite + ? dryR + generatedMid - generatedSide + : 0.0f; + if (! std::isfinite(outputL)) + { + outputL = 0.0f; + ++nonFiniteWetSamples; + } + if (! std::isfinite(outputR)) + { + outputR = 0.0f; + ++nonFiniteWetSamples; + } + buffer.setSample( + 0, + sample, + outputL); + buffer.setSample( + 1, + sample, + outputR); + } + else + { + // A side field has no valid mono destination. Discarding it here + // makes mono processing identical to a post-process L/R fold. + float output = directLIsFinite ? dryL + generatedMid : 0.0f; + if (! std::isfinite(output)) + { + output = 0.0f; + ++nonFiniteWetSamples; + } + buffer.setSample(0, sample, output); + } + } + + if (roomGainTarget <= 0.0f && currentRoomGain <= dormantThreshold) + invalidateRoomHistory(); + if (doublerGainTarget <= 0.0f && currentDoublerGain <= dormantThreshold) + invalidateDoublerHistory(); + + diagnosticNonFiniteInputSamples.fetch_add( + nonFiniteInputSamples, + std::memory_order_relaxed); + diagnosticNonFiniteWetSamples.fetch_add( + nonFiniteWetSamples, + std::memory_order_relaxed); + diagnosticLastDryPeak.store(dryPeak, std::memory_order_relaxed); + diagnosticLastGeneratedMidPeak.store(generatedMidPeak, std::memory_order_relaxed); + diagnosticLastGeneratedSidePeak.store(generatedSidePeak, std::memory_order_relaxed); +} + +NAMCabPresentation::DiagnosticSnapshot NAMCabPresentation::getDiagnostics() const noexcept +{ + DiagnosticSnapshot result; + result.processedBlocks = diagnosticProcessedBlocks.load(std::memory_order_relaxed); + result.processedSamples = diagnosticProcessedSamples.load(std::memory_order_relaxed); + result.zeroEffectFastPathBlocks = diagnosticZeroEffectBlocks.load(std::memory_order_relaxed); + result.oversizedBlocks = diagnosticOversizedBlocks.load(std::memory_order_relaxed); + result.nonFiniteInputSamples = diagnosticNonFiniteInputSamples.load(std::memory_order_relaxed); + result.nonFiniteWetSamples = diagnosticNonFiniteWetSamples.load(std::memory_order_relaxed); + result.lastDryPeak = diagnosticLastDryPeak.load(std::memory_order_relaxed); + result.lastGeneratedMidPeak = diagnosticLastGeneratedMidPeak.load(std::memory_order_relaxed); + result.lastGeneratedSidePeak = diagnosticLastGeneratedSidePeak.load(std::memory_order_relaxed); + return result; +} + +void NAMCabPresentation::resetDiagnostics() noexcept +{ + diagnosticProcessedBlocks.store(0u, std::memory_order_relaxed); + diagnosticProcessedSamples.store(0u, std::memory_order_relaxed); + diagnosticZeroEffectBlocks.store(0u, std::memory_order_relaxed); + diagnosticOversizedBlocks.store(0u, std::memory_order_relaxed); + diagnosticNonFiniteInputSamples.store(0u, std::memory_order_relaxed); + diagnosticNonFiniteWetSamples.store(0u, std::memory_order_relaxed); + diagnosticLastDryPeak.store(0.0f, std::memory_order_relaxed); + diagnosticLastGeneratedMidPeak.store(0.0f, std::memory_order_relaxed); + diagnosticLastGeneratedSidePeak.store(0.0f, std::memory_order_relaxed); +} + +NAMCabPresentation::SelfTestResult NAMCabPresentation::runDeterministicSelfTest() +{ + constexpr int sampleRate = 48000; + constexpr int testSamples = 8192; + SelfTestResult result; + result.lowFrequencySideToMidLimitDb = lowFrequencySideToMidLimitDb; + result.highFrequencySideRmsMinimum = highFrequencySideRmsMinimum; + result.automationOutputPeakLimit = automationOutputPeakLimit; + result.automationDezipperErrorLimit = automationDezipperErrorLimit; + + juce::AudioBuffer<float> unityInput(2, testSamples); + fillDeterministicTestSignal(unityInput); + juce::AudioBuffer<float> unityOutput; + unityOutput.makeCopyOf(unityInput); + NAMCabPresentation unityProcessor; + unityProcessor.prepare(sampleRate, 64); + processInPartitions(unityProcessor, unityOutput, 8); + result.zeroEffectMaximumError = maximumAbsoluteDifference(unityInput, unityOutput); + result.zeroEffectUnity = result.zeroEffectMaximumError == 0.0f; + + NAMCabPresentation delayControlProcessor; + const bool defaultDelayValid = + delayControlProcessor.getParameters().doublerDelayMs == 4.5f; + delayControlProcessor.setDoublerDelayMs(-100.0f); + const bool minimumDelayValid = + delayControlProcessor.getParameters().doublerDelayMs == 3.0f; + delayControlProcessor.setDoublerDelayMs(100.0f); + const bool maximumDelayValid = + delayControlProcessor.getParameters().doublerDelayMs == 20.0f; + delayControlProcessor.setDoublerDelayMs( + std::numeric_limits<float>::quiet_NaN()); + const bool malformedDelayValid = + delayControlProcessor.getParameters().doublerDelayMs == 4.5f; + result.doublerDelayControlValid = defaultDelayValid + && minimumDelayValid + && maximumDelayValid + && malformedDelayValid; + + Parameters activeParameters; + activeParameters.roomAmount = 0.65f; + activeParameters.roomWidth = 0.82f; + activeParameters.doublerMix = 0.72f; + activeParameters.doublerSpread = 0.78f; + activeParameters.doublerDelayMs = 4.5f; + + juce::AudioBuffer<float> firstPass; + firstPass.makeCopyOf(unityInput); + NAMCabPresentation deterministicProcessor; + deterministicProcessor.setParameters(activeParameters); + deterministicProcessor.prepare(sampleRate, 64); + processInPartitions(deterministicProcessor, firstPass, 64); + deterministicProcessor.reset(); + juce::AudioBuffer<float> secondPass; + secondPass.makeCopyOf(unityInput); + processInPartitions(deterministicProcessor, secondPass, 64); + result.deterministicResetMaximumError = maximumAbsoluteDifference(firstPass, secondPass); + result.deterministicReset = result.deterministicResetMaximumError <= selfTestTolerance; + + NAMCabPresentation partitionProcessor; + partitionProcessor.setParameters(activeParameters); + partitionProcessor.prepare(sampleRate, testSamples); + juce::AudioBuffer<float> singleBlock; + singleBlock.makeCopyOf(unityInput); + partitionProcessor.process(singleBlock); + partitionProcessor.reset(); + juce::AudioBuffer<float> partitioned; + partitioned.makeCopyOf(unityInput); + processInPartitions(partitionProcessor, partitioned, 13); + result.blockPartitionMaximumError = maximumAbsoluteDifference(singleBlock, partitioned); + result.blockPartitionInvariant = result.blockPartitionMaximumError <= selfTestTolerance; + + NAMCabPresentation stereoProcessor; + NAMCabPresentation monoProcessor; + stereoProcessor.setParameters(activeParameters); + monoProcessor.setParameters(activeParameters); + stereoProcessor.prepare(sampleRate, 64); + monoProcessor.prepare(sampleRate, 64); + juce::AudioBuffer<float> stereoSignal; + stereoSignal.makeCopyOf(unityInput); + juce::AudioBuffer<float> monoSignal(1, testSamples); + monoSignal.copyFrom(0, 0, unityInput, 0, 0, testSamples); + processInPartitions(stereoProcessor, stereoSignal, 8); + processInPartitions(monoProcessor, monoSignal, 8); + float monoFoldError = 0.0f; + for (int sample = 0; sample < testSamples; ++sample) + { + const float folded = (stereoSignal.getSample(0, sample) + + stereoSignal.getSample(1, sample)) * 0.5f; + monoFoldError = juce::jmax( + monoFoldError, + std::abs(folded - monoSignal.getSample(0, sample))); + } + result.monoFoldMaximumError = monoFoldError; + result.algebraicSideCancellation = monoFoldError <= selfTestTolerance; + + Parameters roomOnly; + roomOnly.roomAmount = 1.0f; + roomOnly.roomWidth = 0.65f; + roomOnly.doublerMix = 0.0f; + roomOnly.doublerSpread = 0.65f; + NAMCabPresentation stereoRoomProcessor; + NAMCabPresentation monoRoomProcessor; + stereoRoomProcessor.setParameters(roomOnly); + monoRoomProcessor.setParameters(roomOnly); + stereoRoomProcessor.prepare(sampleRate, 64); + monoRoomProcessor.prepare(sampleRate, 64); + juce::AudioBuffer<float> stereoRoomSignal; + stereoRoomSignal.makeCopyOf(unityInput); + juce::AudioBuffer<float> monoRoomSignal(1, testSamples); + monoRoomSignal.copyFrom(0, 0, unityInput, 0, 0, testSamples); + processInPartitions(stereoRoomProcessor, stereoRoomSignal, 8); + processInPartitions(monoRoomProcessor, monoRoomSignal, 8); + float monoRoomSidePeak = 0.0f; + float monoRoomFoldError = 0.0f; + for (int sample = 0; sample < testSamples; ++sample) + { + const float roomLeft = stereoRoomSignal.getSample(0, sample); + const float roomRight = stereoRoomSignal.getSample(1, sample); + monoRoomSidePeak = juce::jmax( + monoRoomSidePeak, + std::abs((roomLeft - roomRight) * 0.5f)); + const float folded = (roomLeft + roomRight) * 0.5f; + monoRoomFoldError = juce::jmax( + monoRoomFoldError, + std::abs(folded - monoRoomSignal.getSample(0, sample))); + } + result.monoRoomGeneratedSidePeak = monoRoomSidePeak; + result.monoRoomFoldMaximumError = monoRoomFoldError; + result.monoRoomCreatesStereo = monoRoomSidePeak >= 1.0e-4f; + result.monoRoomFoldContractValid = monoRoomFoldError <= selfTestTolerance; + + NAMCabPresentation roomProcessor; + roomProcessor.setParameters(roomOnly); + roomProcessor.prepare(sampleRate, 64); + constexpr int impulseSamples = 12000; + juce::AudioBuffer<float> impulse(2, impulseSamples); + impulse.clear(); + impulse.setSample(0, 0, 1.0f); + impulse.setSample(1, 0, 1.0f); + processInPartitions(roomProcessor, impulse, 8); + result.expectedRoomFirstArrivalSample = juce::roundToInt(3.1f * 0.001f * sampleRate); + for (int sample = 1; sample < impulseSamples; ++sample) + { + const float wetL = impulse.getSample(0, sample); + const float wetR = impulse.getSample(1, sample); + if (std::abs(wetL) > 1.0e-8f || std::abs(wetR) > 1.0e-8f) + { + result.observedRoomFirstArrivalSample = sample; + break; + } + } + result.roomFirstArrivalValid = + result.observedRoomFirstArrivalSample == result.expectedRoomFirstArrivalSample; + + float preArrivalError = 0.0f; + for (int sample = 0; + sample < result.expectedRoomFirstArrivalSample; + ++sample) + { + const float expected = sample == 0 ? 1.0f : 0.0f; + preArrivalError = juce::jmax( + preArrivalError, + std::abs(impulse.getSample(0, sample) - expected)); + preArrivalError = juce::jmax( + preArrivalError, + std::abs(impulse.getSample(1, sample) - expected)); + } + result.preArrivalDirectMaximumError = preArrivalError; + result.preArrivalDirectExact = preArrivalError == 0.0f; + + constexpr int lateRoomMeasurementStart = sampleRate * 3 / 20; + constexpr int lateRoomMeasurementEnd = sampleRate / 5; + double lateRoomEnergy = 0.0; + int lateRoomMeasurementSamples = 0; + for (int sample = lateRoomMeasurementStart; + sample < lateRoomMeasurementEnd; + ++sample) + { + const double left = static_cast<double>(impulse.getSample(0, sample)); + const double right = static_cast<double>(impulse.getSample(1, sample)); + lateRoomEnergy += left * left + right * right; + lateRoomMeasurementSamples += 2; + } + result.lateRoom150msRms = lateRoomMeasurementSamples > 0 + ? static_cast<float>(std::sqrt( + lateRoomEnergy / static_cast<double>(lateRoomMeasurementSamples))) + : 0.0f; + result.lateRoomFieldValid = result.lateRoom150msRms >= 1.0e-7f; + + Parameters gatedRoom = roomOnly; + gatedRoom.roomInputSendEnabled = false; + NAMCabPresentation gatedNewInputProcessor; + gatedNewInputProcessor.setParameters(gatedRoom); + gatedNewInputProcessor.prepare(sampleRate, 64); + juce::AudioBuffer<float> gatedNewInputReference; + gatedNewInputReference.makeCopyOf(unityInput); + juce::AudioBuffer<float> gatedNewInputOutput; + gatedNewInputOutput.makeCopyOf(unityInput); + processInPartitions(gatedNewInputProcessor, gatedNewInputOutput, 8); + result.gatedRoomNewInputMaximumError = maximumAbsoluteDifference( + gatedNewInputReference, + gatedNewInputOutput); + + NAMCabPresentation gatedTailProcessor; + gatedTailProcessor.setParameters(roomOnly); + gatedTailProcessor.prepare(sampleRate, 64); + juce::AudioBuffer<float> gatedTailExcitation(2, sampleRate / 8); + gatedTailExcitation.clear(); + gatedTailExcitation.setSample(0, 0, 1.0f); + gatedTailExcitation.setSample(1, 0, 1.0f); + processInPartitions(gatedTailProcessor, gatedTailExcitation, 8); + gatedTailProcessor.setRoomInputSendEnabled(false); + juce::AudioBuffer<float> gatedTailDrain(2, sampleRate / 4); + gatedTailDrain.clear(); + processInPartitions(gatedTailProcessor, gatedTailDrain, 8); + float gatedTailPeak = 0.0f; + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; sample < gatedTailDrain.getNumSamples(); ++sample) + { + gatedTailPeak = juce::jmax( + gatedTailPeak, + std::abs(gatedTailDrain.getSample(channel, sample))); + } + } + result.gatedRoomTailPeak = gatedTailPeak; + result.roomInputSendGateValid = + result.gatedRoomNewInputMaximumError == 0.0f + && result.gatedRoomTailPeak >= 1.0e-7f; + bool multiRateTimingValid = true; + constexpr std::array<int, 3> roomTimingSampleRates { 44100, 48000, 96000 }; + for (const int timingSampleRate : roomTimingSampleRates) + { + NAMCabPresentation timingProcessor; + timingProcessor.setParameters(roomOnly); + timingProcessor.prepare(timingSampleRate, 64); + const int timingSamples = timingSampleRate / 10; + juce::AudioBuffer<float> timingImpulse(2, timingSamples); + timingImpulse.clear(); + timingImpulse.setSample(0, 0, 1.0f); + timingImpulse.setSample(1, 0, 1.0f); + processInPartitions(timingProcessor, timingImpulse, 8); + const int expectedFirstArrival = juce::roundToInt( + 3.1f * 0.001f * static_cast<float>(timingSampleRate)); + int observedFirstArrival = -1; + float timingDirectError = 0.0f; + for (int sample = 0; sample < timingSamples; ++sample) + { + const float expected = sample == 0 ? 1.0f : 0.0f; + if (sample < expectedFirstArrival) + { + timingDirectError = juce::jmax( + timingDirectError, + std::abs(timingImpulse.getSample(0, sample) - expected)); + timingDirectError = juce::jmax( + timingDirectError, + std::abs(timingImpulse.getSample(1, sample) - expected)); + } + if (sample > 0 + && observedFirstArrival < 0 + && (std::abs(timingImpulse.getSample(0, sample)) > 1.0e-8f + || std::abs(timingImpulse.getSample(1, sample)) > 1.0e-8f)) + { + observedFirstArrival = sample; + } + } + multiRateTimingValid = multiRateTimingValid + && timingDirectError == 0.0f + && observedFirstArrival == expectedFirstArrival; + } + result.multiRateRoomTimingValid = multiRateTimingValid; + + struct ToneFieldMeasurement + { + float wetMidRms = 0.0f; + float sideRms = 0.0f; + }; + const auto measureRoomTone = [&roomOnly] (float frequencyHz, + int toneSampleRate) + { + const int toneSamples = juce::jmax(4096, toneSampleRate / 2); + Parameters wideRoom = roomOnly; + wideRoom.roomWidth = 1.0f; + NAMCabPresentation toneProcessor; + toneProcessor.setParameters(wideRoom); + toneProcessor.prepare(toneSampleRate, 64); + juce::AudioBuffer<float> tone(2, toneSamples); + for (int sample = 0; sample < toneSamples; ++sample) + { + const float phase = static_cast<float>(sample) + * (2.0f * juce::MathConstants<float>::pi + * frequencyHz / static_cast<float>(toneSampleRate)); + const float value = std::sin(phase) * 0.25f; + tone.setSample(0, sample, value); + tone.setSample(1, sample, value); + } + processInPartitions(toneProcessor, tone, 8); + double midEnergy = 0.0; + double sideEnergy = 0.0; + const int measurementStart = toneSamples / 2; + for (int sample = measurementStart; sample < toneSamples; ++sample) + { + const double left = static_cast<double>(tone.getSample(0, sample)); + const double right = static_cast<double>(tone.getSample(1, sample)); + const double phase = static_cast<double>(sample) + * (2.0 * juce::MathConstants<double>::pi + * static_cast<double>(frequencyHz) + / static_cast<double>(toneSampleRate)); + const double dry = std::sin(phase) * 0.25; + const double wetMid = (left + right) * 0.5 - dry; + const double side = (left - right) * 0.5; + midEnergy += wetMid * wetMid; + sideEnergy += side * side; + } + const double inverseCount = 1.0 + / static_cast<double>(toneSamples - measurementStart); + ToneFieldMeasurement measurement; + measurement.wetMidRms = static_cast<float>( + std::sqrt(midEnergy * inverseCount)); + measurement.sideRms = static_cast<float>( + std::sqrt(sideEnergy * inverseCount)); + return measurement; + }; + + constexpr std::array<int, 3> testSampleRates { 44100, 48000, 96000 }; + float worstLowFrequencyRatioDb = -160.0f; + float minimumHighFrequencySideRms = std::numeric_limits<float>::max(); + for (const int testSampleRate : testSampleRates) + { + const auto lowTone = measureRoomTone(80.0f, testSampleRate); + const auto highTone = measureRoomTone(1000.0f, testSampleRate); + const float lowRatioDb = lowTone.sideRms > 0.0f + ? 20.0f * std::log10( + lowTone.sideRms + / juce::jmax(1.0e-12f, lowTone.wetMidRms)) + : -160.0f; + worstLowFrequencyRatioDb = juce::jmax( + worstLowFrequencyRatioDb, + lowRatioDb); + minimumHighFrequencySideRms = juce::jmin( + minimumHighFrequencySideRms, + highTone.sideRms); + } + result.room80HzSideToMidDb = worstLowFrequencyRatioDb; + result.room1kHzSideRms = minimumHighFrequencySideRms; + result.lowFrequencyRoomFieldCentred = + result.room80HzSideToMidDb <= lowFrequencySideToMidLimitDb; + result.highFrequencyRoomSidePresent = + result.room1kHzSideRms >= highFrequencySideRmsMinimum; + + Parameters automationPrefill; + automationPrefill.roomAmount = 1.0f; + automationPrefill.roomWidth = 0.0f; + automationPrefill.doublerMix = 1.0f; + automationPrefill.doublerSpread = 0.0f; + NAMCabPresentation automationProcessor; + NAMCabPresentation automationReferenceProcessor; + automationProcessor.setParameters(automationPrefill); + automationReferenceProcessor.setParameters(automationPrefill); + automationProcessor.prepare(sampleRate, 64); + automationReferenceProcessor.prepare(sampleRate, 64); + constexpr int preAutomationSamples = 4096; + constexpr int automationOffSamples = 2400; + constexpr int automatedSamples = 8192; + juce::AudioBuffer<float> preAutomation(2, preAutomationSamples); + for (int sample = 0; sample < preAutomationSamples; ++sample) + { + const float phase = static_cast<float>(sample) + * (2.0f * juce::MathConstants<float>::pi * 311.0f + / static_cast<float>(sampleRate)); + const float value = std::sin(phase) * 0.25f; + preAutomation.setSample(0, sample, value); + preAutomation.setSample(1, sample, value); + } + juce::AudioBuffer<float> preAutomationReference; + preAutomationReference.makeCopyOf(preAutomation); + processInPartitions(automationProcessor, preAutomation, 8); + processInPartitions(automationReferenceProcessor, preAutomationReference, 8); + + Parameters automationZero; + automationZero.roomAmount = 0.0f; + automationZero.roomWidth = 0.0f; + automationZero.doublerMix = 0.0f; + automationZero.doublerSpread = 0.0f; + automationProcessor.setParameters(automationZero); + automationReferenceProcessor.setParameters(automationZero); + juce::AudioBuffer<float> automationOff(2, automationOffSamples); + for (int sample = 0; sample < automationOffSamples; ++sample) + { + const int absoluteSample = preAutomationSamples + sample; + const float phase = static_cast<float>(absoluteSample) + * (2.0f * juce::MathConstants<float>::pi * 311.0f + / static_cast<float>(sampleRate)); + const float value = std::sin(phase) * 0.25f; + automationOff.setSample(0, sample, value); + automationOff.setSample(1, sample, value); + } + juce::AudioBuffer<float> automationOffReference; + automationOffReference.makeCopyOf(automationOff); + processInPartitions(automationProcessor, automationOff, 8); + processInPartitions(automationReferenceProcessor, automationOffReference, 8); + const float previousAutomatedOutput = automationOff.getSample( + 0, automationOffSamples - 1); + const float previousReferenceOutput = automationOffReference.getSample( + 0, automationOffSamples - 1); + + juce::AudioBuffer<float> automationDry(2, automatedSamples); + for (int sample = 0; sample < automatedSamples; ++sample) + { + const int absoluteSample = preAutomationSamples + + automationOffSamples + + sample; + const float phase = static_cast<float>(absoluteSample) + * (2.0f * juce::MathConstants<float>::pi * 311.0f + / static_cast<float>(sampleRate)); + const float value = std::sin(phase) * 0.25f; + automationDry.setSample(0, sample, value); + automationDry.setSample(1, sample, value); + } + juce::AudioBuffer<float> automatedOutput; + automatedOutput.makeCopyOf(automationDry); + juce::AudioBuffer<float> automationReferenceOutput; + automationReferenceOutput.makeCopyOf(automationDry); + Parameters automationMaximum; + automationMaximum.roomAmount = 1.0f; + automationMaximum.roomWidth = 1.0f; + automationMaximum.doublerMix = 1.0f; + automationMaximum.doublerSpread = 1.0f; + automationProcessor.setParameters(automationMaximum); + processInPartitions(automationProcessor, automatedOutput, 8); + processInPartitions( + automationReferenceProcessor, + automationReferenceOutput, + 8); + + bool automationFinite = true; + float automationPeak = 0.0f; + for (int channel = 0; channel < automatedOutput.getNumChannels(); ++channel) + { + for (int sample = 0; sample < automatedOutput.getNumSamples(); ++sample) + { + const float value = automatedOutput.getSample(channel, sample); + if (! std::isfinite(value)) + automationFinite = false; + else + automationPeak = juce::jmax(automationPeak, std::abs(value)); + } + } + float previousAutomationDifference = previousAutomatedOutput + - previousReferenceOutput; + float first32DezipperError = 0.0f; + for (int sample = 0; sample < 32; ++sample) + { + const float automationDifference = automatedOutput.getSample(0, sample) + - automationReferenceOutput.getSample(0, sample); + first32DezipperError = juce::jmax( + first32DezipperError, + std::abs(automationDifference - previousAutomationDifference)); + previousAutomationDifference = automationDifference; + } + const int postMorphStart = juce::jmin( + automatedSamples - 1, + juce::jmax( + juce::roundToInt(delayMorphSeconds * sampleRate), + juce::roundToInt(0.020f * sampleRate)) + + 256); + double postMorphDifferenceEnergy = 0.0; + int postMorphDifferenceSamples = 0; + for (int sample = postMorphStart; sample < automatedSamples; ++sample) + { + for (int channel = 0; channel < 2; ++channel) + { + const double difference = static_cast<double>( + automatedOutput.getSample(channel, sample) + - automationReferenceOutput.getSample(channel, sample)); + postMorphDifferenceEnergy += difference * difference; + ++postMorphDifferenceSamples; + } + } + const float postMorphDifferenceRms = postMorphDifferenceSamples > 0 + ? static_cast<float>(std::sqrt( + postMorphDifferenceEnergy + / static_cast<double>(postMorphDifferenceSamples))) + : 0.0f; + result.automationMaximumOutputPeak = automationPeak; + result.automationFirst32DezipperError = first32DezipperError; + result.automationPostMorphDifferenceRms = postMorphDifferenceRms; + result.automationFiniteAndBounded = automationFinite + && automationPeak <= automationOutputPeakLimit; + result.automationDezippered = + first32DezipperError <= automationDezipperErrorLimit; + result.automationPostArrivalExercised = + postMorphDifferenceRms >= 1.0e-4f; + + NAMCabPresentation transientProcessor; + transientProcessor.setParameters(automationMaximum); + transientProcessor.prepare(sampleRate, 64); + juce::AudioBuffer<float> transientImpulse(2, 1); + transientImpulse.setSample(0, 0, 1.0f); + transientImpulse.setSample(1, 0, 1.0f); + transientProcessor.process(transientImpulse); + result.roomTransientMinimumGain = transientProcessor.roomTransientDuck; + result.doublerTransientMinimumGain = transientProcessor.doublerTransientDuck; + juce::AudioBuffer<float> transientRecovery(2, sampleRate / 2); + transientRecovery.clear(); + processInPartitions(transientProcessor, transientRecovery, 64); + result.roomTransientRecoveredGain = transientProcessor.roomTransientDuck; + result.doublerTransientRecoveredGain = transientProcessor.doublerTransientDuck; + result.transientProtectionValid = + result.roomTransientMinimumGain >= 0.69f + && result.roomTransientMinimumGain <= 0.73f + && result.doublerTransientMinimumGain >= 0.48f + && result.doublerTransientMinimumGain <= 0.52f + && result.roomTransientRecoveredGain >= 0.99f + && result.doublerTransientRecoveredGain >= 0.99f; + + NAMCabPresentation nonFiniteProcessor; + nonFiniteProcessor.setParameters(automationMaximum); + nonFiniteProcessor.prepare(sampleRate, 64); + juce::AudioBuffer<float> nonFiniteWarmup(2, 64); + fillDeterministicTestSignal(nonFiniteWarmup); + nonFiniteProcessor.process(nonFiniteWarmup); + nonFiniteProcessor.roomWetHighPassL.z1 = + std::numeric_limits<float>::quiet_NaN(); + nonFiniteProcessor.roomSideHighPass[0].z2 = + std::numeric_limits<float>::infinity(); + nonFiniteProcessor.doublerWetLowPassR.z1 = + -std::numeric_limits<float>::infinity(); + nonFiniteProcessor.transientFastEnvelope = + std::numeric_limits<float>::quiet_NaN(); + nonFiniteProcessor.roomTransientDuck = + std::numeric_limits<float>::infinity(); + juce::AudioBuffer<float> nonFiniteSignal(2, 4096); + fillDeterministicTestSignal(nonFiniteSignal); + nonFiniteSignal.setSample( + 0, 200, std::numeric_limits<float>::quiet_NaN()); + nonFiniteSignal.setSample( + 1, 400, std::numeric_limits<float>::infinity()); + nonFiniteSignal.setSample( + 0, 600, -std::numeric_limits<float>::infinity()); + nonFiniteSignal.setSample( + 1, 600, std::numeric_limits<float>::quiet_NaN()); + processInPartitions(nonFiniteProcessor, nonFiniteSignal, 8); + bool allRecoveredOutputFinite = true; + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; sample < nonFiniteSignal.getNumSamples(); ++sample) + { + if (! std::isfinite(nonFiniteSignal.getSample(channel, sample))) + allRecoveredOutputFinite = false; + } + } + const auto nonFiniteDiagnostics = nonFiniteProcessor.getDiagnostics(); + const bool invalidDirectSamplesCleared = + nonFiniteSignal.getSample(0, 200) == 0.0f + && nonFiniteSignal.getSample(1, 400) == 0.0f + && nonFiniteSignal.getSample(0, 600) == 0.0f + && nonFiniteSignal.getSample(1, 600) == 0.0f; + result.nonFiniteRecoveryValid = allRecoveredOutputFinite + && invalidDirectSamplesCleared + && nonFiniteDiagnostics.nonFiniteInputSamples == 4u + && nonFiniteDiagnostics.nonFiniteWetSamples >= 1u; + + NAMCabPresentation tailProcessor; + tailProcessor.setParameters(automationMaximum); + tailProcessor.prepare(sampleRate, 64); + constexpr int tailTestSamples = sampleRate * 2; + juce::AudioBuffer<float> tailSignal(2, tailTestSamples); + tailSignal.clear(); + tailSignal.setSample(0, 0, 1.0f); + tailSignal.setSample(1, 0, 1.0f); + processInPartitions(tailProcessor, tailSignal, 8); + float tailEndPeak = 0.0f; + const int tailMeasurementStart = tailTestSamples - sampleRate / 10; + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = tailMeasurementStart; + sample < tailTestSamples; + ++sample) + { + tailEndPeak = juce::jmax( + tailEndPeak, + std::abs(tailSignal.getSample(channel, sample))); + } + } + result.tailEndPeak = tailEndPeak; + result.tailDecayValid = tailEndPeak <= 1.0e-6f; + + result.passed = result.zeroEffectUnity + && result.deterministicReset + && result.blockPartitionInvariant + && result.algebraicSideCancellation + && result.monoRoomCreatesStereo + && result.monoRoomFoldContractValid + && result.roomFirstArrivalValid + && result.lowFrequencyRoomFieldCentred + && result.highFrequencyRoomSidePresent + && result.preArrivalDirectExact + && result.automationFiniteAndBounded + && result.automationDezippered + && result.automationPostArrivalExercised + && result.multiRateRoomTimingValid + && result.transientProtectionValid + && result.nonFiniteRecoveryValid + && result.tailDecayValid + && result.roomInputSendGateValid + && result.lateRoomFieldValid + && result.doublerDelayControlValid; + return result; +} + +NAMCabPresentation::BenchmarkResult NAMCabPresentation::runBenchmark( + BenchmarkMode mode, + double sampleRate, + int blockSize, + int measuredBlocks) +{ + BenchmarkResult result; + result.mode = mode; + result.sampleRate = std::isfinite(sampleRate) + ? juce::jmax(8000.0, sampleRate) + : 48000.0; + result.blockSize = juce::jlimit(1, 8192, blockSize); + result.measuredBlocks = juce::jmax(1000, measuredBlocks); + + Parameters benchmarkParameters; + benchmarkParameters.roomAmount = 1.0f; + benchmarkParameters.roomWidth = 0.80f; + benchmarkParameters.doublerMix = mode == BenchmarkMode::roomAndDoubler + ? 1.0f + : 0.0f; + benchmarkParameters.doublerSpread = mode == BenchmarkMode::roomAndDoubler + ? 1.0f + : 0.65f; + + NAMCabPresentation processor; + processor.setParameters(benchmarkParameters); + processor.prepare(result.sampleRate, result.blockSize); + + juce::AudioBuffer<float> dry(2, result.blockSize); + juce::AudioBuffer<float> processed(2, result.blockSize); + std::uint32_t randomState = 0x243f6a88u; + for (int sample = 0; sample < result.blockSize; ++sample) + { + randomState ^= randomState << 13u; + randomState ^= randomState >> 17u; + randomState ^= randomState << 5u; + const float noise = static_cast<float>(randomState & 0x00ffffffu) + * (2.0f / 16777215.0f) - 1.0f; + const float value = noise * 0.22f; + dry.setSample(0, sample, value); + dry.setSample(1, sample, value); + } + + const auto restoreDry = [&dry, &processed] () noexcept + { + processed.copyFrom(0, 0, dry, 0, 0, dry.getNumSamples()); + processed.copyFrom(1, 0, dry, 1, 0, dry.getNumSamples()); + }; + + constexpr int warmupBlocks = 4096; + for (int block = 0; block < warmupBlocks; ++block) + { + restoreDry(); + processor.process(processed); + } + + std::vector<double> callbackMicroseconds( + static_cast<std::size_t>(result.measuredBlocks), + 0.0); + float processedChecksum = 0.0f; + double elapsedMicroseconds = 0.0; + result.callbackDeadlineMicroseconds = + static_cast<double>(result.blockSize) / result.sampleRate * 1000000.0; + for (int block = 0; block < result.measuredBlocks; ++block) + { + restoreDry(); + const juce::int64 callbackStart = juce::Time::getHighResolutionTicks(); + processor.process(processed); + const juce::int64 callbackTicks = juce::Time::getHighResolutionTicks() + - callbackStart; + const double callbackTimeMicroseconds = + juce::Time::highResolutionTicksToSeconds(callbackTicks) + * 1000000.0; + callbackMicroseconds[static_cast<std::size_t>(block)] = + callbackTimeMicroseconds; + elapsedMicroseconds += callbackTimeMicroseconds; + if (callbackTimeMicroseconds > result.callbackDeadlineMicroseconds) + ++result.deadlineMisses; + processedChecksum += processed.getSample(0, block % result.blockSize) + * 1.0e-9f; + } + std::sort(callbackMicroseconds.begin(), callbackMicroseconds.end()); + const auto percentile = [&callbackMicroseconds] (double proportion) + { + const auto count = callbackMicroseconds.size(); + const auto index = static_cast<std::size_t>(juce::jlimit( + 0.0, + static_cast<double>(count - 1u), + std::ceil(proportion * static_cast<double>(count)) - 1.0)); + return callbackMicroseconds[index]; + }; + + result.netElapsedMilliseconds = elapsedMicroseconds * 0.001; + result.averageMicrosecondsPerBlock = elapsedMicroseconds + / static_cast<double>(result.measuredBlocks); + result.p99Microseconds = percentile(0.99); + result.p999Microseconds = percentile(0.999); + result.maximumMicroseconds = callbackMicroseconds.back(); + result.realtimeDeadlineFraction = result.averageMicrosecondsPerBlock + / juce::jmax(1.0e-12, result.callbackDeadlineMicroseconds); + result.outputChecksum = processedChecksum; + result.generatedSidePeak = processor.getDiagnostics().lastGeneratedSidePeak; + // Percentiles characterize this component's repeatable processing cost. + // A userspace wall-clock maximum (and its derived miss count) can include + // an unrelated Windows scheduler pre-emption, so those values stay in the + // report as diagnostics rather than making a deterministic DSP gate flaky. + result.deadlineCriteriaPassed = + result.averageMicrosecondsPerBlock + <= result.callbackDeadlineMicroseconds * 0.25 + && result.p99Microseconds + <= result.callbackDeadlineMicroseconds * 0.50 + && result.p999Microseconds + <= result.callbackDeadlineMicroseconds * 0.75; + result.valid = std::isfinite(result.averageMicrosecondsPerBlock) + && result.averageMicrosecondsPerBlock >= 0.0 + && result.generatedSidePeak > 1.0e-6f + && result.deadlineCriteriaPassed; + return result; +} + +NAMCabPresentation::BenchmarkResult NAMCabPresentation::runRoomOnlyBenchmark( + double sampleRate, + int blockSize, + int measuredBlocks) +{ + return runBenchmark( + BenchmarkMode::roomOnly, + sampleRate, + blockSize, + measuredBlocks); +} + +NAMCabPresentation::BenchmarkResult NAMCabPresentation::runRoomAndDoublerBenchmark( + double sampleRate, + int blockSize, + int measuredBlocks) +{ + return runBenchmark( + BenchmarkMode::roomAndDoubler, + sampleRate, + blockSize, + measuredBlocks); +} diff --git a/Source/NAMCabPresentation.h b/Source/NAMCabPresentation.h new file mode 100644 index 0000000..e56ca55 --- /dev/null +++ b/Source/NAMCabPresentation.h @@ -0,0 +1,331 @@ +#pragma once + +#include <JuceHeader.h> + +#include <array> +#include <atomic> +#include <cstdint> +#include <vector> + +/** + * Allocation-free post-cabinet presentation field for the NAM Rack. + * + * The processor adds two parallel, wet-only components to an unchanged close + * cabinet signal: + * - a true-stereo 2x2 early-reflection field feeding a short late room; and + * - a deterministic, transient-protected two-voice doubler. + * + * prepare() and reset() are message-thread operations. process() performs no + * allocation, locking, logging, I/O, coefficient construction, or container + * resizing. Parameter targets are lock-free atomics and all audible changes + * are smoothed or delay-head crossfaded in the sample domain. + */ +class NAMCabPresentation final +{ +public: + struct Parameters + { + float roomAmount = 0.0f; // 0..1; zero is exact bypass + float roomWidth = 0.65f; // 0..1; maps to 0..135% wet side + float doublerMix = 0.0f; // 0..1; zero is exact bypass + float doublerSpread = 0.65f; // 0..1 + bool roomInputSendEnabled = true; // false drains the existing room tail + float doublerDelayMs = 4.5f; // 3..20 ms; independent of Spread + }; + + struct DiagnosticSnapshot + { + std::uint32_t processedBlocks = 0; + std::uint32_t processedSamples = 0; + std::uint32_t zeroEffectFastPathBlocks = 0; + std::uint32_t oversizedBlocks = 0; + std::uint32_t nonFiniteInputSamples = 0; + std::uint32_t nonFiniteWetSamples = 0; + float lastDryPeak = 0.0f; + float lastGeneratedMidPeak = 0.0f; + float lastGeneratedSidePeak = 0.0f; + }; + + /** + * Synchronous, allocation-permitted deterministic regression data for + * harnesses. It deliberately does not assert spaciousness, naturalness, + * product-reference similarity, or any other subjective audio quality. + */ + struct SelfTestResult + { + bool passed = false; + bool zeroEffectUnity = false; + bool deterministicReset = false; + bool blockPartitionInvariant = false; + bool algebraicSideCancellation = false; + bool monoRoomCreatesStereo = false; + bool monoRoomFoldContractValid = false; + bool roomFirstArrivalValid = false; + bool lowFrequencyRoomFieldCentred = false; + bool highFrequencyRoomSidePresent = false; + bool preArrivalDirectExact = false; + bool automationFiniteAndBounded = false; + bool automationDezippered = false; + bool automationPostArrivalExercised = false; + bool multiRateRoomTimingValid = false; + bool transientProtectionValid = false; + bool nonFiniteRecoveryValid = false; + bool tailDecayValid = false; + bool roomInputSendGateValid = false; + bool lateRoomFieldValid = false; + bool doublerDelayControlValid = false; + float zeroEffectMaximumError = 0.0f; + float deterministicResetMaximumError = 0.0f; + float blockPartitionMaximumError = 0.0f; + float monoFoldMaximumError = 0.0f; + float monoRoomGeneratedSidePeak = 0.0f; + float monoRoomFoldMaximumError = 0.0f; + float room80HzSideToMidDb = 0.0f; + float room1kHzSideRms = 0.0f; + float preArrivalDirectMaximumError = 0.0f; + float automationMaximumOutputPeak = 0.0f; + float automationFirst32DezipperError = 0.0f; + float automationPostMorphDifferenceRms = 0.0f; + float roomTransientMinimumGain = 1.0f; + float doublerTransientMinimumGain = 1.0f; + float roomTransientRecoveredGain = 1.0f; + float doublerTransientRecoveredGain = 1.0f; + float tailEndPeak = 0.0f; + float gatedRoomTailPeak = 0.0f; + float gatedRoomNewInputMaximumError = 0.0f; + float lateRoom150msRms = 0.0f; + float lowFrequencySideToMidLimitDb = -18.0f; + float highFrequencySideRmsMinimum = 1.0e-4f; + float automationOutputPeakLimit = 4.0f; + float automationDezipperErrorLimit = 5.0e-6f; + int expectedRoomFirstArrivalSample = 0; + int observedRoomFirstArrivalSample = -1; + }; + + enum class BenchmarkMode : std::uint8_t + { + roomOnly = 0, + roomAndDoubler + }; + + struct BenchmarkResult + { + bool valid = false; + BenchmarkMode mode = BenchmarkMode::roomOnly; + double sampleRate = 0.0; + int blockSize = 0; + int measuredBlocks = 0; + double netElapsedMilliseconds = 0.0; + double averageMicrosecondsPerBlock = 0.0; + double p99Microseconds = 0.0; + double p999Microseconds = 0.0; + double maximumMicroseconds = 0.0; + double callbackDeadlineMicroseconds = 0.0; + double realtimeDeadlineFraction = 0.0; + std::uint32_t deadlineMisses = 0; + bool deadlineCriteriaPassed = false; + float outputChecksum = 0.0f; + float generatedSidePeak = 0.0f; + }; + + NAMCabPresentation() = default; + ~NAMCabPresentation() = default; + + void prepare(double sampleRate, int maximumBlockSize); + void reset() noexcept; + + void setParameters(const Parameters& newParameters) noexcept; + [[nodiscard]] Parameters getParameters() const noexcept; + + void setRoomAmount(float amount) noexcept; + void setRoomWidth(float width) noexcept; + void setRoomInputSendEnabled(bool enabled) noexcept; + void setDoublerMix(float mix) noexcept; + void setDoublerSpread(float spread) noexcept; + void setDoublerDelayMs(float delayMs) noexcept; + + /** + * Adds the presentation field in place while preserving the direct signal + * at unity. Stereo side contributions are applied as +S/-S, so they cancel + * algebraically when L/R are folded to mono. Channels above the first two + * are deliberately left unchanged. + */ + void process(juce::AudioBuffer<float>& buffer) noexcept; + + [[nodiscard]] bool isPrepared() const noexcept { return prepared; } + [[nodiscard]] int getLatencySamples() const noexcept { return 0; } + [[nodiscard]] double getMaximumTailSeconds() const noexcept { return 0.80; } + + [[nodiscard]] DiagnosticSnapshot getDiagnostics() const noexcept; + void resetDiagnostics() noexcept; + + [[nodiscard]] static SelfTestResult runDeterministicSelfTest(); + [[nodiscard]] static BenchmarkResult runBenchmark( + BenchmarkMode mode, + double sampleRate = 48000.0, + int blockSize = 8, + int measuredBlocks = 200000); + [[nodiscard]] static BenchmarkResult runRoomOnlyBenchmark( + double sampleRate = 48000.0, + int blockSize = 8, + int measuredBlocks = 200000); + [[nodiscard]] static BenchmarkResult runRoomAndDoublerBenchmark( + double sampleRate = 48000.0, + int blockSize = 8, + int measuredBlocks = 200000); + +private: + struct Biquad + { + float b0 = 1.0f; + float b1 = 0.0f; + float b2 = 0.0f; + float a1 = 0.0f; + float a2 = 0.0f; + float z1 = 0.0f; + float z2 = 0.0f; + + void configureLowPass(double sampleRate, float frequencyHz, float q) noexcept; + void configureHighPass(double sampleRate, float frequencyHz, float q) noexcept; + void reset() noexcept; + [[nodiscard]] float processSample(float sample) noexcept; + }; + + struct DoublerDriftState + { + std::uint32_t randomState = 1; + float offsetStartSamples = 0.0f; + float offsetTargetSamples = 0.0f; + float currentOffsetSamples = 0.0f; + float levelStart = 1.0f; + float levelTarget = 1.0f; + float currentLevel = 1.0f; + int segmentPosition = 0; + int segmentLength = 0; + }; + + static constexpr std::size_t roomTapCount = 8; + static constexpr std::size_t lateRoomLineCount = 4; + static constexpr float parameterSmoothingSeconds = 0.020f; + static constexpr float delayMorphSeconds = 0.030f; + static constexpr float roomFieldNormalisation = 0.42f; + static constexpr float roomMidScale = 0.62f; + static constexpr float lateRoomInputGain = 0.32f; + static constexpr float lateRoomOutputGain = 0.58f; + static constexpr float doublerMidScale = 0.55f; + + static float clampUnit(float value) noexcept; + static float nextRandomSigned(std::uint32_t& state) noexcept; + static float smootherStep(float value) noexcept; + static float raisedCosine(float value) noexcept; + static float mapRoomGain(float amount) noexcept; + static float mapDoublerGain(float amount) noexcept; + static float clampDoublerDelayMs(float delayMs) noexcept; + + void configureFilters() noexcept; + void resetRoomRuntimeState(bool clearStorage) noexcept; + void resetDoublerRuntimeState(bool clearStorage) noexcept; + void invalidateRoomHistory() noexcept; + void invalidateDoublerHistory() noexcept; + + [[nodiscard]] float readRoomSample(const std::vector<float>& ring, + int delaySamples) const noexcept; + [[nodiscard]] float readDoublerSample(const std::vector<float>& ring, + float delaySamples) const noexcept; + void advanceDoublerDrift(DoublerDriftState& state, + float spread) noexcept; + void startDoublerDelayMorph(float requestedDelayMs, + float requestedSpread) noexcept; + void processLateRoom(float inputL, + float inputR, + float& outputL, + float& outputR) noexcept; + + double currentSampleRate = 48000.0; + int preparedMaximumBlockSize = 0; + bool prepared = false; + + std::atomic<float> targetRoomAmount { 0.0f }; + std::atomic<float> targetRoomWidth { 0.65f }; + std::atomic<bool> targetRoomInputSendEnabled { true }; + std::atomic<float> targetDoublerMix { 0.0f }; + std::atomic<float> targetDoublerSpread { 0.65f }; + std::atomic<float> targetDoublerDelayMs { 4.5f }; + + float currentRoomGain = 0.0f; + float currentRoomWidth = 0.65f; + float currentDoublerGain = 0.0f; + float currentDoublerSpread = 0.65f; + float smoothingCoefficient = 1.0f; + + std::vector<float> roomRingL; + std::vector<float> roomRingR; + int roomWriteIndex = 0; + int validRoomHistorySamples = 0; + bool roomDormant = true; + std::array<int, roomTapCount> roomDirectTapSamplesL {}; + std::array<int, roomTapCount> roomDirectTapSamplesR {}; + std::array<int, roomTapCount> roomCrossTapSamplesL {}; + std::array<int, roomTapCount> roomCrossTapSamplesR {}; + std::array<std::vector<float>, lateRoomLineCount> lateRoomRings; + std::array<int, lateRoomLineCount> lateRoomWriteIndices {}; + std::array<int, lateRoomLineCount> lateRoomValidSamples {}; + std::array<float, lateRoomLineCount> lateRoomDampingStates {}; + float lateRoomDampingCoefficient = 1.0f; + float currentLateRoomFeedback = 0.25f; + + Biquad roomWetHighPassL; + Biquad roomWetHighPassR; + Biquad roomWetLowPassL; + Biquad roomWetLowPassR; + std::array<Biquad, 2> roomSideHighPass; + + std::vector<float> doublerRingL; + std::vector<float> doublerRingR; + int doublerWriteIndex = 0; + int validDoublerHistorySamples = 0; + bool doublerDormant = true; + DoublerDriftState doublerDriftL; + DoublerDriftState doublerDriftR; + Biquad doublerWetHighPassL; + Biquad doublerWetHighPassR; + Biquad doublerWetLowPassL; + Biquad doublerWetLowPassR; + std::array<Biquad, 2> doublerSideHighPass; + float transientFastEnvelope = 0.0f; + float transientSlowEnvelope = 0.0f; + float roomTransientDuck = 1.0f; + float doublerTransientDuck = 1.0f; + float fastEnvelopeRelease = 0.0f; + float slowEnvelopeCoefficient = 0.0f; + float transientDuckReleaseCoefficient = 0.0f; + + float activeDelaySpread = 0.65f; + float morphTargetDelaySpread = 0.65f; + float requestedDelaySpread = 0.65f; + float activeDoublerDelayMs = 4.5f; + float morphTargetDoublerDelayMs = 4.5f; + float requestedDoublerDelayMs = 4.5f; + int delayMorphPosition = 0; + int delayMorphLength = 1; + bool delayMorphActive = false; + + std::atomic<std::uint32_t> diagnosticProcessedBlocks { 0 }; + std::atomic<std::uint32_t> diagnosticProcessedSamples { 0 }; + std::atomic<std::uint32_t> diagnosticZeroEffectBlocks { 0 }; + std::atomic<std::uint32_t> diagnosticOversizedBlocks { 0 }; + std::atomic<std::uint32_t> diagnosticNonFiniteInputSamples { 0 }; + std::atomic<std::uint32_t> diagnosticNonFiniteWetSamples { 0 }; + std::atomic<float> diagnosticLastDryPeak { 0.0f }; + std::atomic<float> diagnosticLastGeneratedMidPeak { 0.0f }; + std::atomic<float> diagnosticLastGeneratedSidePeak { 0.0f }; + + static_assert(std::atomic<float>::is_always_lock_free, + "NAM Cab Presentation parameter and metric floats must be lock-free"); + static_assert(std::atomic<bool>::is_always_lock_free, + "NAM Cab Presentation switches must be lock-free"); + static_assert(std::atomic<std::uint32_t>::is_always_lock_free, + "NAM Cab Presentation counters must be lock-free"); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(NAMCabPresentation) +}; diff --git a/Source/NAMDelayRegression.cpp b/Source/NAMDelayRegression.cpp new file mode 100644 index 0000000..3dca137 --- /dev/null +++ b/Source/NAMDelayRegression.cpp @@ -0,0 +1,1793 @@ +#include "NAMDelayRegression.h" +#include "BuiltInEffects2.h" +#include "TrackProcessor.h" + +#include <algorithm> +#include <array> +#include <atomic> +#include <cmath> +#include <cstdint> +#include <limits> +#include <memory> +#include <numeric> +#include <utility> +#include <vector> + +void NAMDelayRegression::addCheck( + juce::Array<juce::var>& targetChecks, + const juce::String& id, + const juce::String& status, + const juce::String& detail, + const juce::var& value) +{ + auto* object = new juce::DynamicObject(); + object->setProperty("id", id); + object->setProperty("status", status); + object->setProperty("detail", detail); + if (! value.isVoid()) + object->setProperty("value", value); + targetChecks.add(juce::var(object)); +} + +void NAMDelayRegression::configureNeutralRack(S13NAMRack& rack) +{ + rack.inputTrimDb.store(0.0f); + rack.outputTrimDb.store(0.0f); + rack.gateThresholdDb.store(-100.0f); + rack.compressorEnabled.store(0.0f); + rack.octaverEnabled.store(0.0f); + rack.precisionDriveEnabled.store(0.0f); + rack.chaosEnabled.store(0.0f); + rack.pedalMix.store(0.0f); + rack.ampEnabled.store(0.0f); + rack.setCabRequestedEnabled(false); + rack.eqEnabled.store(0.0f); + rack.chorusMix.store(0.0f); + rack.modulatorEnabled.store(0.0f); + rack.delayEnabled.store(0.0f); + rack.reverbEnabled.store(0.0f); + rack.auditionSource.store(0.0f); +} + +juce::var NAMDelayRegression::runDelayV10ContractProbe() +{ + constexpr std::array<float, 5> modulationBoundaries { + 0.0f, 0.4999f, 0.5f, 0.9999f, 1.0f + }; + constexpr std::array<int, 5> expectedLeft { + 2, 2, 3, 3, 4 + }; + constexpr std::array<int, 5> expectedPingPongRight { + 3, 3, 4, 4, 4 + }; + bool syncResolverPassed = true; + juce::Array<juce::var> syncCases; + for (size_t index = 0; + index < modulationBoundaries.size(); + ++index) + { + const auto mono = + S13NAMRack::resolveDelaySyncSelection( + modulationBoundaries[index], false); + const auto pingPong = + S13NAMRack::resolveDelaySyncSelection( + modulationBoundaries[index], true); + const bool casePassed = + mono.leftNoteIndex == expectedLeft[index] + && mono.rightNoteIndex == expectedLeft[index] + && pingPong.leftNoteIndex == expectedLeft[index] + && pingPong.rightNoteIndex + == expectedPingPongRight[index]; + syncResolverPassed = + syncResolverPassed && casePassed; + + auto* value = new juce::DynamicObject(); + value->setProperty( + "modulation", modulationBoundaries[index]); + value->setProperty( + "leftNoteIndex", pingPong.leftNoteIndex); + value->setProperty( + "rightNoteIndex", pingPong.rightNoteIndex); + value->setProperty("pass", casePassed); + syncCases.add(juce::var(value)); + } + const auto nonFiniteSync = + S13NAMRack::resolveDelaySyncSelection( + std::numeric_limits<float>::quiet_NaN(), true); + syncResolverPassed = + syncResolverPassed + && nonFiniteSync.leftNoteIndex == 2 + && nonFiniteSync.rightNoteIndex == 3; + + const auto digital = + S13NAMRack::resolveDelayMacroState( + 360.0f, 0.55f, 0.35f, 0.80f, 0.40f, + 0.0f, 1.0f, 1.0f, + S13NAMRack::guitarInstrumentProfile); + const auto tape = + S13NAMRack::resolveDelayMacroState( + 360.0f, 0.55f, 0.35f, 0.80f, 0.40f, + 1.0f, 1.0f, 1.0f, + S13NAMRack::guitarInstrumentProfile); + const auto analog = + S13NAMRack::resolveDelayMacroState( + 360.0f, 0.55f, 0.35f, 0.80f, 0.40f, + 2.0f, 1.0f, 1.0f, + S13NAMRack::guitarInstrumentProfile); + const auto bassTape = + S13NAMRack::resolveDelayMacroState( + 360.0f, 0.55f, 0.35f, 0.80f, 0.40f, + 1.0f, 1.0f, 1.0f, + S13NAMRack::bassInstrumentProfile); + const auto multi = + S13NAMRack::resolveDelayMacroState( + 360.0f, 0.55f, 0.35f, 0.80f, 0.40f, + 3.0f, 1.0f, 1.0f, + S13NAMRack::guitarInstrumentProfile); + const auto dual = + S13NAMRack::resolveDelayMacroState( + 360.0f, 0.55f, 0.35f, 0.80f, 0.40f, + 4.0f, 1.0f, 1.0f, + S13NAMRack::guitarInstrumentProfile); + const auto malformed = + S13NAMRack::resolveDelayMacroState( + std::numeric_limits<float>::quiet_NaN(), + std::numeric_limits<float>::infinity(), + -4.0f, + std::numeric_limits<float>::quiet_NaN(), + 9.0f, + std::numeric_limits<float>::quiet_NaN(), + -1.0f, + 8.0f, + 99); + const auto macroFinite = [] ( + const S13NAMRack::DelayMacroState& state) + { + return std::isfinite(state.timeMsL) + && std::isfinite(state.timeMsR) + && std::isfinite(state.mix) + && std::isfinite(state.dryGain) + && std::isfinite(state.feedbackGain) + && std::isfinite(state.crossFeed) + && std::isfinite(state.lowPassHz) + && std::isfinite(state.highPassHz) + && std::isfinite(state.saturation) + && std::isfinite(state.stereoWidth) + && std::isfinite(state.wowDepthMs) + && std::isfinite(state.wowRateHz) + && std::isfinite(state.flutterDepthMs) + && std::isfinite(state.flutterRateHz) + && std::isfinite(state.duckAttackMs) + && std::isfinite(state.duckReleaseMs) + && std::isfinite(state.duckMaxReduction) + && std::all_of( + state.multiTapRatios.begin(), + state.multiTapRatios.end(), + [] (float value) { return std::isfinite(value); }) + && std::all_of( + state.multiTapWeights.begin(), + state.multiTapWeights.end(), + [] (float value) { return std::isfinite(value); }) + && std::isfinite(state.multiFeedbackGain) + && std::isfinite(state.dualTimeRatio) + && std::isfinite(state.dualFeedbackGain) + && std::isfinite(state.dualLowPassHz) + && std::isfinite(state.dualHighPassHz) + && std::isfinite(state.dualSaturation) + && std::isfinite(state.dualModDepthMs) + && std::isfinite(state.dualModRateHz) + && std::isfinite(state.topologyControl); + }; + const bool macroContractPassed = + macroFinite(digital) + && macroFinite(tape) + && macroFinite(analog) + && macroFinite(bassTape) + && macroFinite(multi) + && macroFinite(dual) + && macroFinite(malformed) + && digital.mode == S13NAMRack::digitalDelayMode + && tape.mode == S13NAMRack::tapeDelayMode + && analog.mode == S13NAMRack::analogDelayMode + && multi.mode == S13NAMRack::multiDelayMode + && dual.mode == S13NAMRack::dualDelayMode + && digital.lowPassHz > tape.lowPassHz + && tape.lowPassHz > analog.lowPassHz + && digital.feedbackGain > tape.feedbackGain + && tape.feedbackGain > analog.feedbackGain + && tape.wowDepthMs > analog.wowDepthMs + && analog.wowDepthMs > 0.0f + && tape.flutterDepthMs > 0.0f + && digital.flutterDepthMs == 0.0f + && analog.flutterDepthMs == 0.0f + // Bass retains the fundamental through its unity dry path while the + // repeat path is filtered more aggressively to prevent low buildup. + && bassTape.highPassHz > tape.highPassHz + && bassTape.lowPassHz > tape.lowPassHz + && std::abs(bassTape.dryGain - 1.0f) <= 1.0e-6f + && std::abs( + tape.dryGain + - std::cos( + 0.35f + * juce::MathConstants<float>::halfPi)) + <= 1.0e-6f + && std::abs( + tape.mix + - std::sin( + 0.35f + * juce::MathConstants<float>::halfPi)) + <= 1.0e-6f + && std::abs(multi.multiTapRatios[0] - 1.0f) <= 1.0e-7f + && std::abs(multi.multiTapRatios[1] - 0.726f) <= 1.0e-6f + && std::abs(multi.multiTapRatios[2] - 0.546f) <= 1.0e-6f + && std::abs(multi.multiTapRatios[3] - 0.374f) <= 1.0e-6f + && std::abs( + std::accumulate( + multi.multiTapWeights.begin(), + multi.multiTapWeights.end(), + 0.0f) + - 1.0f) <= 1.0e-7f + && std::abs(multi.multiFeedbackGain - 0.528f) <= 1.0e-6f + && std::abs(dual.dualTimeRatio - 0.90f) <= 1.0e-6f + && std::abs(dual.dualFeedbackGain - 0.5038f) <= 1.0e-6f + && std::abs(dual.dualLowPassHz - 6400.0f) <= 1.0e-5f + && std::abs(dual.dualSaturation - 0.42f) <= 1.0e-6f + && malformed.mode == S13NAMRack::tapeDelayMode + && std::abs(malformed.timeMsL - 360.0f) <= 1.0e-6f + && malformed.mix == 0.0f + && malformed.duckAmount == 1.0f + && ! malformed.pingPong + && malformed.tempoSync; + + juce::ValueTree legacyState("S13NAMRack"); + legacyState.setProperty( + "namEffectsDspVersion", + S13NAMRack::reverbVoiceIntroducedNAMEffectsDspVersion, + nullptr); + legacyState.setProperty("reverbVoice", 3.0, nullptr); + legacyState.setProperty("delayMix", 4.0, nullptr); + legacyState.setProperty("delayTimeMs", -4.0, nullptr); + legacyState.setProperty("delayFeedback", 7.0, nullptr); + legacyState.setProperty("delayMod", -1.0, nullptr); + legacyState.setProperty("delayDucker", 3.0, nullptr); + legacyState.setProperty("delayMode", 99.0, nullptr); + legacyState.setProperty("delayPingPong", 0.2, nullptr); + legacyState.setProperty("delayTempoSync", 0.8, nullptr); + legacyState.setProperty("delayEnabled", -2.0, nullptr); + legacyState.setProperty("inputMode", 1.0, nullptr); + legacyState.setProperty("auditionSource", 1.0, nullptr); + juce::MemoryBlock migratedState; + { + juce::MemoryOutputStream stream( + migratedState, false); + legacyState.writeToStream(stream); + } + bool firstMigrationChanged = false; + const bool firstMigrationSucceeded = + S13NAMRack::migratePresetStateToCurrent( + migratedState, firstMigrationChanged); + const auto canonicalState = + juce::ValueTree::readFromData( + migratedState.getData(), migratedState.getSize()); + bool secondMigrationChanged = true; + const bool secondMigrationSucceeded = + S13NAMRack::migratePresetStateToCurrent( + migratedState, secondMigrationChanged); + const auto closeProperty = [&canonicalState] ( + const char* property, + double expected) + { + return canonicalState.isValid() + && std::abs( + static_cast<double>( + canonicalState.getProperty(property)) + - expected) <= 1.0e-7; + }; + const bool stateContractPassed = + firstMigrationSucceeded + && firstMigrationChanged + && secondMigrationSucceeded + && ! secondMigrationChanged + && canonicalState.isValid() + && static_cast<int>(canonicalState.getProperty( + "namEffectsDspVersion", 0)) + == S13NAMRack::currentNAMEffectsDspVersion + && closeProperty("delayMix", 1.0) + && closeProperty("delayTimeMs", 1.0) + && closeProperty("delayFeedback", 0.85) + && closeProperty("delayMod", 0.0) + && closeProperty("delayDucker", 1.0) + && closeProperty("delayMode", 2.0) + && closeProperty("delayPingPong", 0.0) + && closeProperty("delayTempoSync", 1.0) + && closeProperty("delayEnabled", 0.0) + && ! canonicalState.hasProperty("inputMode") + && ! canonicalState.hasProperty("auditionSource") + && closeProperty("reverbVoice", 3.0); + + juce::ValueTree currentState("S13NAMRack"); + currentState.setProperty( + "namEffectsDspVersion", + S13NAMRack::currentNAMEffectsDspVersion, + nullptr); + currentState.setProperty("delayMode", 99.0, nullptr); + currentState.setProperty("inputMode", 1.0, nullptr); + currentState.setProperty("auditionSource", 1.0, nullptr); + juce::MemoryBlock currentStateData; + { + juce::MemoryOutputStream stream(currentStateData, false); + currentState.writeToStream(stream); + } + bool currentStateChanged = false; + const bool currentStateSucceeded = + S13NAMRack::migratePresetStateToCurrent( + currentStateData, + currentStateChanged); + const auto canonicalCurrentState = + juce::ValueTree::readFromData( + currentStateData.getData(), + currentStateData.getSize()); + const bool currentStateContractPassed = + currentStateSucceeded + && currentStateChanged + && canonicalCurrentState.isValid() + && std::abs(static_cast<double>( + canonicalCurrentState.getProperty("delayMode")) - 4.0) + <= 1.0e-7 + && ! canonicalCurrentState.hasProperty("inputMode") + && ! canonicalCurrentState.hasProperty("auditionSource"); + + const auto migrateModeFixture = [] ( + const juce::var& version, + bool includeVersion) + { + juce::ValueTree tree("S13NAMRack"); + if (includeVersion) + { + tree.setProperty( + "namEffectsDspVersion", version, nullptr); + } + tree.setProperty("delayMode", 4.0, nullptr); + juce::MemoryBlock data; + { + juce::MemoryOutputStream stream(data, false); + tree.writeToStream(stream); + } + bool changed = false; + if (! S13NAMRack::migratePresetStateToCurrent( + data, changed)) + return -1.0; + const auto migrated = juce::ValueTree::readFromData( + data.getData(), data.getSize()); + return migrated.isValid() + ? static_cast<double>( + migrated.getProperty("delayMode", -1.0)) + : -1.0; + }; + const double missingVersionMode = migrateModeFixture( + {}, false); + const double v10Mode = migrateModeFixture( + S13NAMRack::delayV10IntroducedNAMEffectsDspVersion, + true); + const double developmentAliasMode = migrateModeFixture( + S13NAMRack::developmentNAMEffectsDspVersionAlias, true); + const double futureVersionMode = migrateModeFixture( + S13NAMRack::currentNAMEffectsDspVersion + 1, true); + const double fractionalVersionMode = migrateModeFixture( + static_cast<double>( + S13NAMRack::currentNAMEffectsDspVersion) + 0.7, + true); + + const auto migrateNestedModeFixture = [] ( + int parentVersion) + { + auto* ui = new juce::DynamicObject(); + auto* baseline = new juce::DynamicObject(); + auto* values = new juce::DynamicObject(); + values->setProperty("delayMode", 4.0); + baseline->setProperty("values", juce::var(values)); + ui->setProperty( + "namPresetBaseline", juce::var(baseline)); + juce::ValueTree tree("S13NAMRack"); + tree.setProperty( + "namEffectsDspVersion", parentVersion, nullptr); + tree.setProperty( + "uiStateJSON", + juce::JSON::toString(juce::var(ui), false), + nullptr); + juce::MemoryBlock data; + { + juce::MemoryOutputStream stream(data, false); + tree.writeToStream(stream); + } + bool changed = false; + if (! S13NAMRack::migratePresetStateToCurrent( + data, changed)) + return -1.0; + const auto migrated = juce::ValueTree::readFromData( + data.getData(), data.getSize()); + const auto migratedUi = juce::JSON::parse( + migrated.getProperty("uiStateJSON", {}).toString()); + if (auto* migratedUiObject = + migratedUi.getDynamicObject()) + { + if (auto* migratedBaseline = + migratedUiObject->getProperty( + "namPresetBaseline").getDynamicObject()) + { + if (auto* migratedValues = + migratedBaseline->getProperty( + "values").getDynamicObject()) + { + return static_cast<double>( + migratedValues->getProperty("delayMode")); + } + } + } + return -1.0; + }; + const double nestedLegacyMode = + migrateNestedModeFixture(7); + const double nestedV10Mode = + migrateNestedModeFixture( + S13NAMRack::delayV10IntroducedNAMEffectsDspVersion); + const double nestedCurrentMode = + migrateNestedModeFixture( + S13NAMRack::currentNAMEffectsDspVersion); + const double nestedDevelopmentAliasMode = + migrateNestedModeFixture( + S13NAMRack::developmentNAMEffectsDspVersionAlias); + const bool versionMigrationContractPassed = + std::abs(missingVersionMode - 1.0) <= 1.0e-7 + && std::abs(v10Mode - 4.0) <= 1.0e-7 + && std::abs(developmentAliasMode - 4.0) <= 1.0e-7 + && std::abs(futureVersionMode - 1.0) <= 1.0e-7 + && std::abs(fractionalVersionMode - 1.0) <= 1.0e-7 + && std::abs(nestedLegacyMode - 2.0) <= 1.0e-7 + && std::abs(nestedV10Mode - 4.0) <= 1.0e-7 + && std::abs(nestedCurrentMode - 4.0) <= 1.0e-7 + && std::abs(nestedDevelopmentAliasMode - 4.0) <= 1.0e-7; + + S13NAMRack malformedSaveRack; + const float nonFinite = + std::numeric_limits<float>::quiet_NaN(); + malformedSaveRack.delayMix.store(nonFinite); + malformedSaveRack.delayTimeMs.store(nonFinite); + malformedSaveRack.delayFeedback.store(nonFinite); + malformedSaveRack.delayMod.store(nonFinite); + malformedSaveRack.delayDucker.store(nonFinite); + malformedSaveRack.delayMode.store(nonFinite); + malformedSaveRack.delayPingPong.store(nonFinite); + malformedSaveRack.delayTempoSync.store(nonFinite); + malformedSaveRack.delayEnabled.store(nonFinite); + juce::MemoryBlock malformedSaveState; + malformedSaveRack.getStateInformation(malformedSaveState); + const auto canonicalSavedState = + juce::ValueTree::readFromData( + malformedSaveState.getData(), + malformedSaveState.getSize()); + const bool saveBoundaryContractPassed = + canonicalSavedState.isValid() + && std::abs(static_cast<double>( + canonicalSavedState.getProperty("delayMix")) - 0.22) + <= 1.0e-7 + && std::abs(static_cast<double>( + canonicalSavedState.getProperty("delayTimeMs")) - 360.0) + <= 1.0e-7 + && std::abs(static_cast<double>( + canonicalSavedState.getProperty("delayFeedback")) - 0.22) + <= 1.0e-7 + && std::abs(static_cast<double>( + canonicalSavedState.getProperty("delayMode")) - 1.0) + <= 1.0e-7 + && std::abs(static_cast<double>( + canonicalSavedState.getProperty("delayPingPong")) - 1.0) + <= 1.0e-7 + && std::abs(static_cast<double>( + canonicalSavedState.getProperty("delayTempoSync"))) + <= 1.0e-7 + && std::abs(static_cast<double>( + canonicalSavedState.getProperty("delayEnabled"))) + <= 1.0e-7; + + auto* value = new juce::DynamicObject(); + value->setProperty("syncCases", syncCases); + value->setProperty( + "syncResolverPassed", syncResolverPassed); + value->setProperty( + "macroContractPassed", macroContractPassed); + value->setProperty( + "stateContractPassed", stateContractPassed); + value->setProperty( + "currentStateContractPassed", + currentStateContractPassed); + value->setProperty( + "versionMigrationContractPassed", + versionMigrationContractPassed); + value->setProperty( + "saveBoundaryContractPassed", + saveBoundaryContractPassed); + value->setProperty( + "missingVersionMode", missingVersionMode); + value->setProperty( + "developmentAliasMode", developmentAliasMode); + value->setProperty( + "futureVersionMode", futureVersionMode); + value->setProperty( + "fractionalVersionMode", fractionalVersionMode); + value->setProperty( + "nestedLegacyMode", nestedLegacyMode); + value->setProperty( + "nestedCurrentMode", nestedCurrentMode); + value->setProperty( + "nestedDevelopmentAliasMode", + nestedDevelopmentAliasMode); + value->setProperty( + "digitalLowPassHz", digital.lowPassHz); + value->setProperty( + "tapeLowPassHz", tape.lowPassHz); + value->setProperty( + "analogLowPassHz", analog.lowPassHz); + value->setProperty( + "bassTapeHighPassHz", bassTape.highPassHz); + value->setProperty( + "guitarTapeHighPassHz", tape.highPassHz); + value->setProperty( + "pass", + syncResolverPassed + && macroContractPassed + && stateContractPassed + && currentStateContractPassed + && versionMigrationContractPassed + && saveBoundaryContractPassed); + return juce::var(value); +} + +juce::var NAMDelayRegression::runDelayV10AudioProbe() +{ + const auto configureDelay = [] ( + S13Delay& delay, + const S13NAMRack::DelayMacroState& state) + { + delay.setExtendedModesEnabled(true); + delay.delayTimeL.store(state.timeMsL); + delay.delayTimeR.store(state.timeMsR); + delay.feedback.store(state.feedbackGain); + delay.crossFeed.store(state.crossFeed); + delay.mix.store(state.mix); + delay.pingPong.store(state.pingPong ? 1.0f : 0.0f); + delay.tempoSync.store(state.tempoSync ? 1.0f : 0.0f); + delay.syncNoteL.store( + static_cast<float>(state.sync.leftNoteIndex)); + delay.syncNoteR.store( + static_cast<float>(state.sync.rightNoteIndex)); + delay.lpfFreq.store(state.lowPassHz); + delay.hpfFreq.store(state.highPassHz); + delay.fbSaturation.store(state.saturation); + delay.stereoWidth.store(state.stereoWidth); + delay.delayMode.store(static_cast<float>(state.mode)); + delay.ducking.store(state.duckAmount); + delay.wowDepthMs.store(state.wowDepthMs); + delay.wowRateHz.store(state.wowRateHz); + delay.flutterDepthMs.store(state.flutterDepthMs); + delay.flutterRateHz.store(state.flutterRateHz); + delay.duckAttackMs.store(state.duckAttackMs); + delay.duckReleaseMs.store(state.duckReleaseMs); + delay.duckMaxReduction.store(state.duckMaxReduction); + delay.topologyControl.store(state.topologyControl); + delay.multiFeedback.store(state.multiFeedbackGain); + delay.dualTimeRatio.store(state.dualTimeRatio); + delay.dualFeedback.store(state.dualFeedbackGain); + delay.dualLowPassHz.store(state.dualLowPassHz); + delay.dualHighPassHz.store(state.dualHighPassHz); + delay.dualSaturation.store(state.dualSaturation); + delay.dualModDepthMs.store(state.dualModDepthMs); + delay.dualModRateHz.store(state.dualModRateHz); + delay.inputSend.store(1.0f); + delay.unityDry.store( + state.dryGain >= 0.9999f ? 1.0f : 0.0f); + }; + + juce::Array<juce::var> timingCases; + bool timingPassed = true; + for (const double sampleRate : + { 44100.0, 48000.0, 96000.0 }) + { + constexpr float requestedDelayMs = 125.0f; + const int totalSamples = juce::roundToInt( + sampleRate * 0.16); + const int expectedSample = juce::roundToInt( + sampleRate + * static_cast<double>(requestedDelayMs) + * 0.001); + S13Delay delay(0.25f); + const auto state = + S13NAMRack::resolveDelayMacroState( + requestedDelayMs, 0.0f, 1.0f, + 0.0f, 0.0f, 0.0f, + 0.0f, 0.0f, + S13NAMRack::guitarInstrumentProfile); + configureDelay(delay, state); + delay.prepareToPlay(sampleRate, 64); + + constexpr std::array<int, 6> pattern { + 7, 31, 5, 64, 13, 47 + }; + int cursor = 0; + int patternIndex = 0; + int peakSample = -1; + float peak = 0.0f; + double absoluteWetSum = 0.0; + bool finite = true; + juce::MidiBuffer midi; + while (cursor < totalSamples) + { + const int blockSize = juce::jmin( + pattern[static_cast<size_t>( + patternIndex % pattern.size())], + totalSamples - cursor); + juce::AudioBuffer<float> block(2, blockSize); + block.clear(); + if (cursor == 0) + { + block.setSample(0, 0, 1.0f); + block.setSample(1, 0, 1.0f); + } + delay.processBlock(block, midi); + for (int sample = 0; sample < blockSize; ++sample) + { + const float output = + block.getSample(0, sample); + finite = finite && std::isfinite(output); + absoluteWetSum += std::abs( + static_cast<double>(output)); + if (std::abs(output) > peak) + { + peak = std::abs(output); + peakSample = cursor + sample; + } + } + cursor += blockSize; + ++patternIndex; + } + const int timingErrorSamples = + peakSample >= 0 + ? std::abs(peakSample - expectedSample) + : totalSamples; + const bool casePassed = + finite + // A fractional linear-interpolation tap can split a unity + // impulse evenly across its two neighbouring samples. Directly + // after reset, the complete-history guard intentionally rejects + // the earlier half until both interpolation samples are valid; + // the surviving half is the correct first-repeat oracle here. + && absoluteWetSum > 0.49 + && peak > 0.45f + && timingErrorSamples <= 2; + timingPassed = timingPassed && casePassed; + + auto* value = new juce::DynamicObject(); + value->setProperty("sampleRate", sampleRate); + value->setProperty( + "expectedSample", expectedSample); + value->setProperty("peakSample", peakSample); + value->setProperty( + "timingErrorSamples", timingErrorSamples); + value->setProperty("peak", peak); + value->setProperty( + "absoluteWetSum", absoluteWetSum); + value->setProperty("pass", casePassed); + timingCases.add(juce::var(value)); + } + + constexpr double renderSampleRate = 48000.0; + constexpr int maximumBlockSize = 64; + constexpr int renderSamples = 24000; + const auto renderMode = [ + &configureDelay, + renderSampleRate, + maximumBlockSize, + renderSamples] ( + int mode, + const std::array<int, 6>& blockPattern) + { + S13Delay delay(1.0f); + const auto state = + S13NAMRack::resolveDelayMacroState( + 37.0f, 0.62f, 1.0f, + 0.80f, 0.0f, + static_cast<float>(mode), + 1.0f, 0.0f, + S13NAMRack::guitarInstrumentProfile); + configureDelay(delay, state); + delay.prepareToPlay( + renderSampleRate, maximumBlockSize); + + juce::AudioBuffer<float> capture( + 2, renderSamples); + capture.clear(); + juce::MidiBuffer midi; + int cursor = 0; + int patternIndex = 0; + while (cursor < renderSamples) + { + const int blockSize = juce::jmin( + blockPattern[static_cast<size_t>( + patternIndex % blockPattern.size())], + renderSamples - cursor); + juce::AudioBuffer<float> block(2, blockSize); + for (int sample = 0; sample < blockSize; ++sample) + { + const int absoluteSample = cursor + sample; + const double time = + static_cast<double>(absoluteSample) + / renderSampleRate; + const float transient = + absoluteSample % 1601 == 0 + ? 0.32f + : 0.0f; + const float left = transient + + 0.12f * static_cast<float>(std::sin( + juce::MathConstants<double>::twoPi + * 173.0 * time)) + + 0.07f * static_cast<float>(std::sin( + juce::MathConstants<double>::twoPi + * 3191.0 * time + 0.23)); + const float right = -0.70f * transient + + 0.10f * static_cast<float>(std::sin( + juce::MathConstants<double>::twoPi + * 241.0 * time + 0.47)) + + 0.06f * static_cast<float>(std::sin( + juce::MathConstants<double>::twoPi + * 5117.0 * time + 0.91)); + block.setSample(0, sample, left); + block.setSample(1, sample, right); + } + delay.processBlock(block, midi); + capture.copyFrom( + 0, cursor, block, 0, 0, blockSize); + capture.copyFrom( + 1, cursor, block, 1, 0, blockSize); + cursor += blockSize; + ++patternIndex; + } + return capture; + }; + constexpr std::array<int, 6> uniformPattern { + 64, 64, 64, 64, 64, 64 + }; + constexpr std::array<int, 6> irregularPattern { + 7, 31, 5, 64, 13, 47 + }; + const auto tapeUniform = renderMode( + S13NAMRack::tapeDelayMode, uniformPattern); + const auto tapeIrregular = renderMode( + S13NAMRack::tapeDelayMode, irregularPattern); + const auto digital = renderMode( + S13NAMRack::digitalDelayMode, irregularPattern); + const auto analog = renderMode( + S13NAMRack::analogDelayMode, irregularPattern); + const auto multiUniform = renderMode( + S13NAMRack::multiDelayMode, uniformPattern); + const auto multiIrregular = renderMode( + S13NAMRack::multiDelayMode, irregularPattern); + const auto dualUniform = renderMode( + S13NAMRack::dualDelayMode, uniformPattern); + const auto dualIrregular = renderMode( + S13NAMRack::dualDelayMode, irregularPattern); + const auto maximumDifference = [] ( + const juce::AudioBuffer<float>& first, + const juce::AudioBuffer<float>& second) + { + float maximum = 0.0f; + for (int channel = 0; channel < 2; ++channel) + for (int sample = 0; + sample < first.getNumSamples(); + ++sample) + maximum = juce::jmax( + maximum, + std::abs( + first.getSample(channel, sample) + - second.getSample(channel, sample))); + return maximum; + }; + const auto differenceRms = [] ( + const juce::AudioBuffer<float>& first, + const juce::AudioBuffer<float>& second) + { + double energy = 0.0; + int count = 0; + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; + sample < first.getNumSamples(); + ++sample) + { + const double difference = + static_cast<double>( + first.getSample(channel, sample)) + - static_cast<double>( + second.getSample(channel, sample)); + energy += difference * difference; + ++count; + } + } + return count > 0 + ? std::sqrt(energy / static_cast<double>(count)) + : 0.0; + }; + const auto bufferIsFinite = [] ( + const juce::AudioBuffer<float>& capture) + { + for (int channel = 0; + channel < capture.getNumChannels(); + ++channel) + for (int sample = 0; + sample < capture.getNumSamples(); + ++sample) + if (! std::isfinite( + capture.getSample(channel, sample))) + return false; + return true; + }; + const float partitionMaximumDifference = + maximumDifference(tapeUniform, tapeIrregular); + const float multiPartitionMaximumDifference = + maximumDifference(multiUniform, multiIrregular); + const float dualPartitionMaximumDifference = + maximumDifference(dualUniform, dualIrregular); + const double digitalTapeDifference = + differenceRms(digital, tapeIrregular); + const double tapeAnalogDifference = + differenceRms(tapeIrregular, analog); + const double digitalAnalogDifference = + differenceRms(digital, analog); + const double multiDualDifference = + differenceRms(multiIrregular, dualIrregular); + const double tapeMultiDifference = + differenceRms(tapeIrregular, multiIrregular); + const bool partitionAndDistinctnessPassed = + bufferIsFinite(tapeUniform) + && bufferIsFinite(tapeIrregular) + && bufferIsFinite(digital) + && bufferIsFinite(analog) + && bufferIsFinite(multiUniform) + && bufferIsFinite(multiIrregular) + && bufferIsFinite(dualUniform) + && bufferIsFinite(dualIrregular) + && partitionMaximumDifference <= 2.0e-6f + && multiPartitionMaximumDifference <= 2.0e-6f + && dualPartitionMaximumDifference <= 2.0e-6f + && digitalTapeDifference > 1.0e-4 + && tapeAnalogDifference > 1.0e-4 + && digitalAnalogDifference > 1.0e-4 + && multiDualDifference > 1.0e-4 + && tapeMultiDifference > 1.0e-4; + + juce::Array<juce::var> tailCases; + bool tailContractPassed = true; + for (int mode = S13NAMRack::digitalDelayMode; + mode <= S13NAMRack::dualDelayMode; + ++mode) + { + S13Delay delay(3.0f); + const auto state = + S13NAMRack::resolveDelayMacroState( + 360.0f, 0.55f, 0.35f, + 0.80f, 0.0f, + static_cast<float>(mode), + 1.0f, 0.0f, + S13NAMRack::guitarInstrumentProfile); + configureDelay(delay, state); + delay.prepareToPlay(44100.0, 64); + double maximumFeedback = static_cast<double>(state.feedbackGain); + if (mode == S13NAMRack::multiDelayMode) + { + maximumFeedback = juce::jmax( + maximumFeedback, + static_cast<double>(state.multiFeedbackGain)); + } + else if (mode == S13NAMRack::dualDelayMode) + { + maximumFeedback = juce::jmax( + maximumFeedback, + static_cast<double>(state.dualFeedbackGain)); + } + const double repeatsToMinus60 = + std::log( + 0.001 + / juce::jmax( + 1.0, + static_cast<double>(state.stereoWidth))) + / std::log(maximumFeedback); + double maximumModulationMs = static_cast<double>( + state.wowDepthMs + state.flutterDepthMs); + if (mode == S13NAMRack::dualDelayMode) + { + maximumModulationMs = juce::jmax( + maximumModulationMs, + static_cast<double>(state.dualModDepthMs)); + } + const double expectedTailSeconds = + ((static_cast<double>(juce::jmax( + state.timeMsL, state.timeMsR)) + + maximumModulationMs) + * 0.001 + + 1.0 / 44100.0) + * juce::jmax(1.0, repeatsToMinus60 + 1.0); + const double reportedTailSeconds = + delay.getTailLengthSeconds(); + const bool casePassed = + std::isfinite(reportedTailSeconds) + && reportedTailSeconds > 0.0 + && std::abs( + reportedTailSeconds + - expectedTailSeconds) <= 1.0e-5; + tailContractPassed = + tailContractPassed && casePassed; + + auto* value = new juce::DynamicObject(); + value->setProperty("mode", mode); + value->setProperty( + "reportedTailSeconds", reportedTailSeconds); + value->setProperty( + "expectedTailSeconds", expectedTailSeconds); + value->setProperty("pass", casePassed); + tailCases.add(juce::var(value)); + } + + // Use an integral one-millisecond tap. At 44.1 kHz the separate complete- + // interpolation-history guard intentionally rejects the first fractional + // lobe, which makes its excitation peak unsuitable as a width oracle. + constexpr double widthTailSampleRate = 48000.0; + constexpr int widthTailBlockSize = 64; + constexpr float widthTailFeedback = 0.80f; + constexpr float widthTailDelayMs = 1.0f; + S13Delay widthTailDelay(3.0f); + auto widthTailState = S13NAMRack::resolveDelayMacroState( + widthTailDelayMs, + widthTailFeedback, + 1.0f, + 0.0f, + 0.0f, + static_cast<float>(S13NAMRack::digitalDelayMode), + 0.0f, + 0.0f, + S13NAMRack::guitarInstrumentProfile); + widthTailState.stereoWidth = 2.0f; + widthTailState.lowPassHz = 20000.0f; + widthTailState.highPassHz = 20.0f; + widthTailState.saturation = 0.0f; + configureDelay(widthTailDelay, widthTailState); + widthTailDelay.prepareToPlay( + widthTailSampleRate, widthTailBlockSize); + const double widthAwareExpectedTailSeconds = + (static_cast<double>(widthTailDelayMs) * 0.001 + + 1.0 / widthTailSampleRate) + * (std::log(0.001 / 2.0) + / std::log(static_cast<double>(widthTailFeedback)) + + 1.0); + const double widthAwareReportedTailSeconds = + widthTailDelay.getTailLengthSeconds(); + const int widthTailDeclaredSamples = static_cast<int>(std::ceil( + widthAwareReportedTailSeconds * widthTailSampleRate)); + const int widthTailRenderSamples = + widthTailDeclaredSamples + + juce::roundToInt( + widthTailSampleRate + * static_cast<double>(widthTailDelayMs) * 0.002); + float widthTailPeak = 0.0f; + float widthTailPostDeclarationPeak = 0.0f; + juce::MidiBuffer widthTailMidi; + for (int cursor = 0; + cursor < widthTailRenderSamples; + cursor += widthTailBlockSize) + { + const int blockSamples = juce::jmin( + widthTailBlockSize, + widthTailRenderSamples - cursor); + juce::AudioBuffer<float> block(2, blockSamples); + block.clear(); + if (cursor == 0) + { + block.setSample(0, 0, 1.0f); + block.setSample(1, 0, -1.0f); + } + widthTailDelay.processBlock(block, widthTailMidi); + for (int sample = 0; sample < blockSamples; ++sample) + { + const float samplePeak = juce::jmax( + std::abs(block.getSample(0, sample)), + std::abs(block.getSample(1, sample))); + widthTailPeak = juce::jmax(widthTailPeak, samplePeak); + if (cursor + sample >= widthTailDeclaredSamples) + { + widthTailPostDeclarationPeak = juce::jmax( + widthTailPostDeclarationPeak, samplePeak); + } + } + } + const bool widthAwareTailPassed = + std::abs( + widthAwareReportedTailSeconds + - widthAwareExpectedTailSeconds) <= 1.0e-5 + && widthTailPeak >= 0.25f + && widthTailPostDeclarationPeak <= 0.00105f; + + auto* value = new juce::DynamicObject(); + value->setProperty("timingCases", timingCases); + value->setProperty( + "partitionMaximumDifference", + partitionMaximumDifference); + value->setProperty( + "multiPartitionMaximumDifference", + multiPartitionMaximumDifference); + value->setProperty( + "dualPartitionMaximumDifference", + dualPartitionMaximumDifference); + value->setProperty( + "digitalTapeDifferenceRms", + digitalTapeDifference); + value->setProperty( + "tapeAnalogDifferenceRms", + tapeAnalogDifference); + value->setProperty( + "digitalAnalogDifferenceRms", + digitalAnalogDifference); + value->setProperty( + "multiDualDifferenceRms", + multiDualDifference); + value->setProperty( + "tapeMultiDifferenceRms", + tapeMultiDifference); + value->setProperty("tailCases", tailCases); + value->setProperty( + "widthAwareExpectedTailSeconds", + widthAwareExpectedTailSeconds); + value->setProperty( + "widthAwareReportedTailSeconds", + widthAwareReportedTailSeconds); + value->setProperty( + "widthTailPostDeclarationPeak", + widthTailPostDeclarationPeak); + value->setProperty("widthTailPeak", widthTailPeak); + value->setProperty( + "widthAwareTailPassed", widthAwareTailPassed); + value->setProperty( + "pass", + timingPassed + && partitionAndDistinctnessPassed + && tailContractPassed + && widthAwareTailPassed); + return juce::var(value); +} + +juce::var NAMDelayRegression::runDelayV10Stage3TopologyProbe() +{ + constexpr int topologyBlockSize = 64; + constexpr std::array<int, 4> multiRatioNumerators { + 43, 61, 79, 100 + }; + constexpr std::array<float, 4> multiWeights { + 0.13f, 0.20f, 0.25f, 0.42f + }; + constexpr std::array<int, 4> multiStereoChannels { + 1, 0, 1, 0 + }; + + const auto configureExactDelay = [] ( + S13Delay& delay, + double sampleRate, + int baseDelaySamples, + int mode, + float secondaryFeedback) + { + const float delayMs = static_cast<float>( + static_cast<double>(baseDelaySamples) + * 1000.0 / sampleRate); + delay.setExtendedModesEnabled(true); + delay.delayTimeL.store(delayMs); + delay.delayTimeR.store(delayMs); + delay.feedback.store(0.0f); + delay.crossFeed.store(0.0f); + delay.mix.store(1.0f); + delay.pingPong.store(0.0f); + delay.tempoSync.store(0.0f); + delay.syncNoteL.store(2.0f); + delay.syncNoteR.store(2.0f); + delay.lpfFreq.store(20000.0f); + delay.hpfFreq.store(20.0f); + delay.fbSaturation.store(0.0f); + delay.stereoWidth.store(1.0f); + delay.delayMode.store(static_cast<float>(mode)); + delay.ducking.store(0.0f); + delay.wowDepthMs.store(0.0f); + delay.wowRateHz.store(0.25f); + delay.flutterDepthMs.store(0.0f); + delay.flutterRateHz.store(6.4f); + delay.duckAttackMs.store(8.0f); + delay.duckReleaseMs.store(180.0f); + delay.duckMaxReduction.store(0.82f); + delay.topologyControl.store(0.0f); + delay.multiFeedback.store(0.0f); + delay.dualTimeRatio.store(0.5f); + delay.dualFeedback.store(secondaryFeedback); + delay.dualLowPassHz.store(20000.0f); + delay.dualHighPassHz.store(20.0f); + delay.dualSaturation.store(0.0f); + delay.dualModDepthMs.store(0.0f); + delay.dualModRateHz.store(0.25f); + delay.inputSend.store(1.0f); + delay.unityDry.store(0.0f); + }; + + const auto renderImpulse = [&configureExactDelay] ( + double sampleRate, + int baseDelaySamples, + int mode, + int numChannels, + int rightImpulseSample, + float secondaryFeedback, + int totalSamples) + { + constexpr int blockSize = 64; + S13Delay delay(0.5f); + configureExactDelay( + delay, + sampleRate, + baseDelaySamples, + mode, + secondaryFeedback); + delay.prepareToPlay(sampleRate, blockSize); + + juce::AudioBuffer<float> capture( + numChannels, totalSamples); + capture.clear(); + juce::MidiBuffer midi; + int cursor = 0; + while (cursor < totalSamples) + { + const int blockSamples = juce::jmin( + blockSize, totalSamples - cursor); + juce::AudioBuffer<float> block( + numChannels, blockSamples); + block.clear(); + if (cursor == 0) + block.setSample(0, 0, 1.0f); + if (numChannels > 1 + && rightImpulseSample >= cursor + && rightImpulseSample < cursor + blockSamples) + { + block.setSample( + 1, + rightImpulseSample - cursor, + 1.0f); + } + delay.processBlock(block, midi); + for (int channel = 0; + channel < numChannels; + ++channel) + { + capture.copyFrom( + channel, + cursor, + block, + channel, + 0, + blockSamples); + } + cursor += blockSamples; + } + return capture; + }; + + const auto windowPeak = [] ( + const juce::AudioBuffer<float>& capture, + int channel, + int centreSample, + int radius) + { + float peak = 0.0f; + for (int sample = juce::jmax(0, centreSample - radius); + sample <= juce::jmin( + capture.getNumSamples() - 1, + centreSample + radius); + ++sample) + { + peak = juce::jmax( + peak, + std::abs(capture.getSample(channel, sample))); + } + return peak; + }; + + const auto captureIsFinite = [] ( + const juce::AudioBuffer<float>& capture) + { + for (int channel = 0; + channel < capture.getNumChannels(); + ++channel) + { + for (int sample = 0; + sample < capture.getNumSamples(); + ++sample) + { + if (! std::isfinite( + capture.getSample(channel, sample))) + { + return false; + } + } + } + return true; + }; + + bool exactTopologyPassed = true; + juce::Array<juce::var> exactRateCases; + constexpr std::array<double, 3> topologySampleRates { + 44100.0, 48000.0, 96000.0 + }; + constexpr std::array<int, 3> exactBaseDelaySamples { + 4400, 4800, 9600 + }; + for (size_t rateIndex = 0; + rateIndex < topologySampleRates.size(); + ++rateIndex) + { + const double sampleRate = topologySampleRates[rateIndex]; + const int baseDelaySamples = + exactBaseDelaySamples[rateIndex]; + const int rightImpulseOffset = baseDelaySamples / 20; + const int totalSamples = + baseDelaySamples + rightImpulseOffset + 16; + const auto multiStereo = renderImpulse( + sampleRate, + baseDelaySamples, + S13NAMRack::multiDelayMode, + 2, + -1, + 0.0f, + totalSamples); + const auto multiMono = renderImpulse( + sampleRate, + baseDelaySamples, + S13NAMRack::multiDelayMode, + 1, + -1, + 0.0f, + totalSamples); + const auto dualStereo = renderImpulse( + sampleRate, + baseDelaySamples, + S13NAMRack::dualDelayMode, + 2, + rightImpulseOffset, + 0.0f, + totalSamples); + + bool multiStereoPassed = + captureIsFinite(multiStereo); + bool multiMonoPassed = captureIsFinite(multiMono); + float multiMonoAbsoluteSum = 0.0f; + float multiStereoAbsoluteSum = 0.0f; + float maximumMultiWeightError = 0.0f; + float maximumMultiWrongChannelPeak = 0.0f; + for (size_t tap = 0; + tap < multiRatioNumerators.size(); + ++tap) + { + const int tapSample = + baseDelaySamples + * multiRatioNumerators[tap] / 100; + const int stereoChannel = + multiStereoChannels[tap]; + const float stereoPeak = windowPeak( + multiStereo, stereoChannel, tapSample, 2); + const float wrongChannelPeak = windowPeak( + multiStereo, + 1 - stereoChannel, + tapSample, + 2); + const float monoPeak = windowPeak( + multiMono, 0, tapSample, 2); + maximumMultiWeightError = juce::jmax( + maximumMultiWeightError, + juce::jmax( + std::abs(stereoPeak - multiWeights[tap]), + std::abs(monoPeak - multiWeights[tap]))); + maximumMultiWrongChannelPeak = juce::jmax( + maximumMultiWrongChannelPeak, + wrongChannelPeak); + multiStereoPassed = multiStereoPassed + && std::abs(stereoPeak - multiWeights[tap]) + <= 1.0e-4f + && wrongChannelPeak <= 1.0e-6f; + multiMonoPassed = multiMonoPassed + && std::abs(monoPeak - multiWeights[tap]) + <= 1.0e-4f; + } + for (int sample = 0; + sample < multiMono.getNumSamples(); + ++sample) + { + multiMonoAbsoluteSum += std::abs( + multiMono.getSample(0, sample)); + multiStereoAbsoluteSum += std::abs( + multiStereo.getSample(0, sample)); + multiStereoAbsoluteSum += std::abs( + multiStereo.getSample(1, sample)); + } + multiMonoPassed = multiMonoPassed + && std::abs(multiMonoAbsoluteSum - 1.0f) + <= 5.0e-4f; + multiStereoPassed = multiStereoPassed + && std::abs(multiStereoAbsoluteSum - 1.0f) + <= 5.0e-4f; + + const int secondaryLeftSample = baseDelaySamples / 2; + const int secondaryRightSample = + secondaryLeftSample + rightImpulseOffset; + const int primaryLeftSample = baseDelaySamples; + const int primaryRightSample = + primaryLeftSample + rightImpulseOffset; + const float secondaryLeftPeak = windowPeak( + dualStereo, 0, secondaryLeftSample, 2); + const float secondaryLeftWrongChannel = windowPeak( + dualStereo, 1, secondaryLeftSample, 2); + const float secondaryRightPeak = windowPeak( + dualStereo, 1, secondaryRightSample, 2); + const float secondaryRightWrongChannel = windowPeak( + dualStereo, 0, secondaryRightSample, 2); + const float primaryLeftPeak = windowPeak( + dualStereo, 0, primaryLeftSample, 2); + const float primaryLeftWrongChannel = windowPeak( + dualStereo, 1, primaryLeftSample, 2); + const float primaryRightPeak = windowPeak( + dualStereo, 1, primaryRightSample, 2); + const float primaryRightWrongChannel = windowPeak( + dualStereo, 0, primaryRightSample, 2); + float dualAbsoluteSum = 0.0f; + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; + sample < dualStereo.getNumSamples(); + ++sample) + { + dualAbsoluteSum += std::abs( + dualStereo.getSample(channel, sample)); + } + } + const bool dualIndependentPassed = + captureIsFinite(dualStereo) + && std::abs(secondaryLeftPeak - 0.35f) + <= 1.0e-4f + && std::abs(secondaryRightPeak - 0.35f) + <= 1.0e-4f + && std::abs(primaryLeftPeak - 0.65f) + <= 1.0e-4f + && std::abs(primaryRightPeak - 0.65f) + <= 1.0e-4f + && secondaryLeftWrongChannel <= 1.0e-6f + && secondaryRightWrongChannel <= 1.0e-6f + && primaryLeftWrongChannel <= 1.0e-6f + && primaryRightWrongChannel <= 1.0e-6f + && std::abs(dualAbsoluteSum - 2.0f) + <= 1.0e-3f; + const bool casePassed = + multiStereoPassed + && multiMonoPassed + && dualIndependentPassed; + exactTopologyPassed = + exactTopologyPassed && casePassed; + + auto* value = new juce::DynamicObject(); + value->setProperty("sampleRate", sampleRate); + value->setProperty( + "baseDelaySamples", baseDelaySamples); + value->setProperty( + "maximumMultiWeightError", + maximumMultiWeightError); + value->setProperty( + "maximumMultiWrongChannelPeak", + maximumMultiWrongChannelPeak); + value->setProperty( + "multiMonoAbsoluteSum", + multiMonoAbsoluteSum); + value->setProperty( + "multiStereoAbsoluteSum", + multiStereoAbsoluteSum); + value->setProperty( + "dualSecondaryLeftPeak", + secondaryLeftPeak); + value->setProperty( + "dualSecondaryRightPeak", + secondaryRightPeak); + value->setProperty( + "dualPrimaryLeftPeak", + primaryLeftPeak); + value->setProperty( + "dualPrimaryRightPeak", + primaryRightPeak); + value->setProperty( + "dualAbsoluteSum", dualAbsoluteSum); + value->setProperty( + "multiStereoPassed", multiStereoPassed); + value->setProperty( + "multiMonoPassed", multiMonoPassed); + value->setProperty( + "dualIndependentPassed", dualIndependentPassed); + value->setProperty("pass", casePassed); + exactRateCases.add(juce::var(value)); + } + + constexpr double primeSampleRate = 48000.0; + constexpr int primeBaseDelaySamples = 4800; + constexpr int primeTotalSamples = 7240; + S13Delay recursiveDual(0.5f); + configureExactDelay( + recursiveDual, + primeSampleRate, + primeBaseDelaySamples, + S13NAMRack::dualDelayMode, + 0.70f); + recursiveDual.prepareToPlay( + primeSampleRate, topologyBlockSize); + juce::AudioBuffer<float> recursiveCapture( + 2, primeTotalSamples); + recursiveCapture.clear(); + juce::MidiBuffer recursiveMidi; + int recursiveCursor = 0; + while (recursiveCursor < primeTotalSamples) + { + const int blockSamples = juce::jmin( + topologyBlockSize, + primeTotalSamples - recursiveCursor); + juce::AudioBuffer<float> block(2, blockSamples); + block.clear(); + if (recursiveCursor == 0) + block.setSample(0, 0, 1.0f); + recursiveDual.processBlock(block, recursiveMidi); + recursiveCapture.copyFrom( + 0, recursiveCursor, block, 0, 0, blockSamples); + recursiveCapture.copyFrom( + 1, recursiveCursor, block, 1, 0, blockSamples); + recursiveCursor += blockSamples; + } + const float independentSecondaryRecursionPeak = windowPeak( + recursiveCapture, + 0, + 3 * primeBaseDelaySamples / 2, + 8); + const bool independentRecursionPassed = + captureIsFinite(recursiveCapture) + && independentSecondaryRecursionPeak > 0.02f; + + S13Delay continuouslyPrimedDual(0.5f); + configureExactDelay( + continuouslyPrimedDual, + primeSampleRate, + primeBaseDelaySamples, + S13NAMRack::digitalDelayMode, + 0.0f); + continuouslyPrimedDual.prepareToPlay( + primeSampleRate, topologyBlockSize); + juce::AudioBuffer<float> primedCapture( + 2, primeBaseDelaySamples / 2 + 16); + primedCapture.clear(); + juce::MidiBuffer primedMidi; + int primedCursor = 0; + while (primedCursor < primedCapture.getNumSamples()) + { + const int blockSamples = juce::jmin( + topologyBlockSize, + primedCapture.getNumSamples() - primedCursor); + juce::AudioBuffer<float> block(2, blockSamples); + block.clear(); + if (primedCursor == 0) + block.setSample(0, 0, 1.0f); + if (primedCursor == topologyBlockSize) + { + continuouslyPrimedDual.delayMode.store( + static_cast<float>(S13NAMRack::dualDelayMode)); + } + continuouslyPrimedDual.processBlock(block, primedMidi); + primedCapture.copyFrom( + 0, primedCursor, block, 0, 0, blockSamples); + primedCapture.copyFrom( + 1, primedCursor, block, 1, 0, blockSamples); + primedCursor += blockSamples; + } + const float continuouslyPrimedSecondaryPeak = windowPeak( + primedCapture, + 0, + primeBaseDelaySamples / 2, + 2); + const bool continuousPrimePassed = + captureIsFinite(primedCapture) + && continuouslyPrimedSecondaryPeak > 0.05f; + + const auto renderTopologyAutomation = [] ( + const std::array<int, 6>& partitions) + { + constexpr double sampleRate = 48000.0; + constexpr int blockSize = 64; + constexpr int totalSamples = 22000; + constexpr std::array<int, 3> eventSamples { + 4096, 11264, 19456 + }; + constexpr std::array<int, 3> eventModes { + S13NAMRack::multiDelayMode, + S13NAMRack::dualDelayMode, + S13NAMRack::tapeDelayMode + }; + S13Delay delay(1.0f); + delay.setExtendedModesEnabled(true); + delay.delayTimeL.store(37.0f); + delay.delayTimeR.store(43.66f); + delay.feedback.store(0.35f); + delay.crossFeed.store(0.12f); + delay.mix.store(1.0f); + delay.pingPong.store(1.0f); + delay.tempoSync.store(0.0f); + delay.lpfFreq.store(9200.0f); + delay.hpfFreq.store(105.0f); + delay.fbSaturation.store(0.22f); + delay.stereoWidth.store(1.08f); + delay.delayMode.store( + static_cast<float>(S13NAMRack::digitalDelayMode)); + delay.ducking.store(0.0f); + delay.wowDepthMs.store(0.32f); + delay.wowRateHz.store(0.37f); + delay.flutterDepthMs.store(0.08f); + delay.flutterRateHz.store(6.2f); + delay.topologyControl.store(0.60f); + delay.multiFeedback.store(0.336f); + delay.dualTimeRatio.store(0.80f); + delay.dualFeedback.store(0.3122f); + delay.dualLowPassHz.store(7000.0f); + delay.dualHighPassHz.store(134.0f); + delay.dualSaturation.store(0.36f); + delay.dualModDepthMs.store(0.57f); + delay.dualModRateHz.store(0.34f); + delay.prepareToPlay(sampleRate, blockSize); + + juce::AudioBuffer<float> capture(2, totalSamples); + capture.clear(); + juce::MidiBuffer midi; + int cursor = 0; + size_t partitionIndex = 0; + size_t eventIndex = 0; + while (cursor < totalSamples) + { + if (eventIndex < eventSamples.size() + && cursor == eventSamples[eventIndex]) + { + delay.delayMode.store(static_cast<float>( + eventModes[eventIndex])); + ++eventIndex; + } + int blockSamples = juce::jmin( + partitions[partitionIndex % partitions.size()], + totalSamples - cursor); + if (eventIndex < eventSamples.size() + && cursor < eventSamples[eventIndex]) + { + blockSamples = juce::jmin( + blockSamples, + eventSamples[eventIndex] - cursor); + } + juce::AudioBuffer<float> block(2, blockSamples); + for (int sample = 0; + sample < blockSamples; + ++sample) + { + const int absoluteSample = cursor + sample; + const double time = + static_cast<double>(absoluteSample) + / sampleRate; + const float marker = + absoluteSample % 1291 == 0 + ? 0.24f + : 0.0f; + block.setSample( + 0, + sample, + marker + + 0.08f * static_cast<float>(std::sin( + juce::MathConstants<double>::twoPi + * 181.0 * time))); + block.setSample( + 1, + sample, + -marker * 0.63f + + 0.07f * static_cast<float>(std::sin( + juce::MathConstants<double>::twoPi + * 263.0 * time + 0.41))); + } + delay.processBlock(block, midi); + capture.copyFrom( + 0, cursor, block, 0, 0, blockSamples); + capture.copyFrom( + 1, cursor, block, 1, 0, blockSamples); + cursor += blockSamples; + ++partitionIndex; + } + return capture; + }; + constexpr std::array<int, 6> fixedPartitions { + 64, 64, 64, 64, 64, 64 + }; + constexpr std::array<int, 6> unevenPartitions { + 7, 31, 5, 64, 13, 47 + }; + const auto fixedAutomation = renderTopologyAutomation( + fixedPartitions); + const auto unevenAutomation = renderTopologyAutomation( + unevenPartitions); + float topologyPartitionDifference = 0.0f; + float topologyAutomationPeak = 0.0f; + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; + sample < fixedAutomation.getNumSamples(); + ++sample) + { + topologyPartitionDifference = juce::jmax( + topologyPartitionDifference, + std::abs( + fixedAutomation.getSample(channel, sample) + - unevenAutomation.getSample(channel, sample))); + topologyAutomationPeak = juce::jmax( + topologyAutomationPeak, + std::abs(fixedAutomation.getSample(channel, sample))); + } + } + const bool topologyAutomationPassed = + captureIsFinite(fixedAutomation) + && captureIsFinite(unevenAutomation) + && topologyPartitionDifference <= 2.0e-6f + && topologyAutomationPeak <= 2.0f; + + S13Delay resetDual(0.5f); + configureExactDelay( + resetDual, + primeSampleRate, + primeBaseDelaySamples, + S13NAMRack::dualDelayMode, + 0.65f); + resetDual.prepareToPlay( + primeSampleRate, topologyBlockSize); + juce::MidiBuffer resetMidi; + for (int cursor = 0; + cursor < primeBaseDelaySamples + topologyBlockSize; + cursor += topologyBlockSize) + { + juce::AudioBuffer<float> block(2, topologyBlockSize); + block.clear(); + if (cursor == 0) + { + block.setSample(0, 0, 0.8f); + block.setSample(1, 7, -0.6f); + } + resetDual.processBlock(block, resetMidi); + } + resetDual.resetTailState(); + resetDual.resetRackRuntimeMixState(0.0f, false); + float resetStalePeak = 0.0f; + for (int cursor = 0; + cursor < primeBaseDelaySamples + topologyBlockSize; + cursor += topologyBlockSize) + { + juce::AudioBuffer<float> block(2, topologyBlockSize); + block.clear(); + resetDual.processBlock(block, resetMidi); + for (int channel = 0; channel < 2; ++channel) + resetStalePeak = juce::jmax( + resetStalePeak, + block.getMagnitude(channel, 0, topologyBlockSize)); + } + resetDual.prepareToPlay( + primeSampleRate, topologyBlockSize); + float reprepareStalePeak = 0.0f; + for (int cursor = 0; + cursor < primeBaseDelaySamples + topologyBlockSize; + cursor += topologyBlockSize) + { + juce::AudioBuffer<float> block(2, topologyBlockSize); + block.clear(); + resetDual.processBlock(block, resetMidi); + for (int channel = 0; channel < 2; ++channel) + reprepareStalePeak = juce::jmax( + reprepareStalePeak, + block.getMagnitude(channel, 0, topologyBlockSize)); + } + const bool resetAndRepreparePassed = + resetStalePeak <= 1.0e-7f + && reprepareStalePeak <= 1.0e-7f; + + const bool pass = + exactTopologyPassed + && independentRecursionPassed + && continuousPrimePassed + && topologyAutomationPassed + && resetAndRepreparePassed; + auto* value = new juce::DynamicObject(); + value->setProperty("exactRateCases", exactRateCases); + value->setProperty( + "independentSecondaryRecursionPeak", + independentSecondaryRecursionPeak); + value->setProperty( + "continuouslyPrimedSecondaryPeak", + continuouslyPrimedSecondaryPeak); + value->setProperty( + "topologyPartitionMaximumDifference", + topologyPartitionDifference); + value->setProperty( + "topologyAutomationPeak", + topologyAutomationPeak); + value->setProperty( + "resetStaleHistoryPeak", resetStalePeak); + value->setProperty( + "reprepareStaleHistoryPeak", reprepareStalePeak); + value->setProperty( + "exactTopologyPassed", exactTopologyPassed); + value->setProperty( + "independentRecursionPassed", + independentRecursionPassed); + value->setProperty( + "continuousPrimePassed", continuousPrimePassed); + value->setProperty( + "topologyAutomationPassed", + topologyAutomationPassed); + value->setProperty( + "resetAndRepreparePassed", + resetAndRepreparePassed); + value->setProperty("pass", pass); + return juce::var(value); +} + +juce::Array<juce::var> NAMDelayRegression::runCoreChecks() +{ + juce::Array<juce::var> checks; + + const auto delayV10ContractProbe = + runDelayV10ContractProbe(); + addCheck( + checks, + "delay_v10_sync_macro_and_state_contract", + delayV10ContractProbe.getProperty("pass", false) + ? "pass" + : "fail", + "Delay V10 must use one monotonic three-step sync resolver, derive all Digital/Tape/Analog/Multi/Dual character and Guitar/Bass support values only from the six faceplate controls plus Instrument, canonicalize all nine saved delay fields across legacy/current/unknown and nested snapshots, preserve V9 Reverb Voice, and make migration idempotent.", + delayV10ContractProbe); + const auto delayV10AudioProbe = + runDelayV10AudioProbe(); + addCheck( + checks, + "delay_v10_timing_partition_modes_and_tail", + delayV10AudioProbe.getProperty("pass", false) + ? "pass" + : "fail", + "Delay V10 must place a 125 ms manual echo within two samples at 44.1/48/96 kHz, render sample-identically across regular and irregular callback partitions, make all five modes objectively distinct, and declare each mode's exact derived -60 dB tail without hidden saved controls.", + delayV10AudioProbe); + const auto delayV10Stage3TopologyProbe = + runDelayV10Stage3TopologyProbe(); + addCheck( + checks, + "delay_v10_multi_dual_topology_objective_matrix", + delayV10Stage3TopologyProbe.getProperty("pass", false) + ? "pass" + : "fail", + "Delay V10 Multi must render the exact four normalized alternating taps in stereo and mono at 44.1/48/96 kHz; Dual must use isolated continuously primed L/R secondary histories with independent recursion; 0-to-Multi-to-Dual-to-Tape automation must remain callback-partition invariant; and logical reset/reprepare must invalidate both histories.", + delayV10Stage3TopologyProbe); + + return checks; +} + +juce::Array<juce::var> NAMDelayRegression::run() +{ + auto checks = runCoreChecks(); + checks.addArray(runLifecycleChecks()); + checks.addArray(runRackTailChecks()); + return checks; +} diff --git a/Source/NAMDelayRegression.h b/Source/NAMDelayRegression.h new file mode 100644 index 0000000..20fe192 --- /dev/null +++ b/Source/NAMDelayRegression.h @@ -0,0 +1,43 @@ +#pragma once + +#include <JuceHeader.h> + +class S13NAMRack; + +// Kept in dedicated translation units so the deterministic Delay/Tape/host- +// tail objective matrix never inflates AudioEngine::runNAMRackRegression or +// one diagnostic helper beyond MSVC's compiler-heap limits. +class NAMDelayRegression final +{ +public: + static juce::Array<juce::var> run(); + +private: + inline static constexpr double fixtureSampleRate = 44100.0; + inline static constexpr int fixtureBlockSize = 512; + + static void addCheck( + juce::Array<juce::var>& targetChecks, + const juce::String& id, + const juce::String& status, + const juce::String& detail, + const juce::var& value = juce::var()); + static void configureNeutralRack(S13NAMRack& rack); + + static juce::Array<juce::var> runCoreChecks(); + static juce::Array<juce::var> runLifecycleChecks(); + static juce::Array<juce::var> runRackTailChecks(); + + static juce::var runDelayV10ContractProbe(); + static juce::var runDelayV10AudioProbe(); + static juce::var runDelayV10Stage3TopologyProbe(); + static juce::var runDelayPlayHeadLifecycleProbe(); + static juce::var runDelayTailLifecycleProbe(); + static juce::var runDelayHighFeedbackDecayProbe(); + static juce::var runDelayFractionalResetProbe(); + static juce::var runStandaloneDelayMalformedAndLegacyModeProbe(); + static juce::var runRackDelaySpilloverProbe(); + static juce::var runRackDelayV10FrozenTailAndBudgetProbe(); + static juce::var runRackMinimumDelayBypassProbe(); + static juce::var runTrackProcessorSparseTailServiceProbe(); +}; diff --git a/Source/NAMDelayRegressionLifecycle.cpp b/Source/NAMDelayRegressionLifecycle.cpp new file mode 100644 index 0000000..91dc717 --- /dev/null +++ b/Source/NAMDelayRegressionLifecycle.cpp @@ -0,0 +1,1427 @@ +#include "NAMDelayRegression.h" +#include "BuiltInEffects2.h" +#include "TrackProcessor.h" + +#include <algorithm> +#include <array> +#include <atomic> +#include <cmath> +#include <cstdint> +#include <limits> +#include <memory> +#include <numeric> +#include <utility> +#include <vector> + +juce::var NAMDelayRegression::runDelayPlayHeadLifecycleProbe() +{ + struct CountingPlayHead final : juce::AudioPlayHead + { + explicit CountingPlayHead(double tempo) : bpm(tempo) {} + + juce::Optional<PositionInfo> getPosition() const override + { + ++positionCalls; + PositionInfo info; + info.setBpm(bpm); + info.setIsPlaying(true); + return info; + } + + double bpm = 60.0; + mutable int positionCalls = 0; + }; + + CountingPlayHead standaloneHead(60.0); + S13Delay standaloneDelay(10.0f); + standaloneDelay.delayTimeL.store(250.0f); + standaloneDelay.delayTimeR.store(250.0f); + standaloneDelay.feedback.store(0.0f); + standaloneDelay.mix.store(1.0f); + standaloneDelay.tempoSync.store(1.0f); + standaloneDelay.syncNoteL.store(2.0f); + standaloneDelay.syncNoteR.store(2.0f); + standaloneDelay.setPlayHead(&standaloneHead); + standaloneDelay.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + const int callsAfterPrepare = + standaloneHead.positionCalls; + const double conservativeUnknownTail = + standaloneDelay.getTailLengthSeconds(); + const int callsAfterInitialTail = + standaloneHead.positionCalls; + standaloneDelay.resetTailState(); + const int callsAfterReset = + standaloneHead.positionCalls; + + juce::AudioBuffer<float> standaloneBlock( + 2, fixtureBlockSize); + standaloneBlock.clear(); + standaloneBlock.setSample(0, 0, 0.5f); + juce::MidiBuffer midi; + standaloneDelay.processBlock(standaloneBlock, midi); + const int callsAfterProcess = + standaloneHead.positionCalls; + const double publishedTempoTail = + standaloneDelay.getTailLengthSeconds(); + const int callsAfterPublishedTail = + standaloneHead.positionCalls; + const float standaloneSnappedSamplesAt60 = + standaloneDelay.requestedDelaySamplesL; + const bool standaloneSnappedWithoutMorphAt60 = + ! standaloneDelay.delayTimeMorphActive + && ! standaloneDelay.delayTimeChangePending; + standaloneDelay.resetTailState(); + const int callsAfterPublishedReset = + standaloneHead.positionCalls; + const float tempoRetainedAcrossLogicalReset = + standaloneDelay.publishedTempoBpm.load( + std::memory_order_relaxed); + standaloneHead.bpm = 10.0; + standaloneDelay.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + const int callsAfterStaleTempoPrepare = + standaloneHead.positionCalls; + const double staleTempoPreCallbackTail = + standaloneDelay.getTailLengthSeconds(); + standaloneBlock.clear(); + standaloneBlock.setSample(0, 0, 0.5f); + standaloneDelay.processBlock(standaloneBlock, midi); + const int callsAfterTenBpmProcess = + standaloneHead.positionCalls; + const float standaloneSnappedSamplesAt10 = + standaloneDelay.requestedDelaySamplesL; + const bool standaloneSnappedWithoutMorphAt10 = + ! standaloneDelay.delayTimeMorphActive + && ! standaloneDelay.delayTimeChangePending; + const double tenBpmPublishedTail = + standaloneDelay.getTailLengthSeconds(); + standaloneDelay.reset(); + const int callsAfterHostReset = + standaloneHead.positionCalls; + const float tempoAfterHostReset = + standaloneDelay.publishedTempoBpm.load( + std::memory_order_relaxed); + const double hostResetPreCallbackTail = + standaloneDelay.getTailLengthSeconds(); + const float expectedQuarterNoteSamples = + static_cast<float>(fixtureSampleRate); + const float expectedTenBpmQuarterSamples = + static_cast<float>(fixtureSampleRate * 6.0); + const bool standaloneLifecyclePassed = + callsAfterPrepare == 0 + && callsAfterInitialTail == 0 + && callsAfterReset == 0 + && callsAfterProcess == 1 + && callsAfterPublishedTail == 1 + && callsAfterPublishedReset == 1 + && callsAfterStaleTempoPrepare == 1 + && callsAfterTenBpmProcess == 2 + && callsAfterHostReset == 2 + && conservativeUnknownTail + >= publishedTempoTail * 5.9 + && std::abs( + standaloneSnappedSamplesAt60 + - expectedQuarterNoteSamples) <= 1.0f + && std::abs( + tempoRetainedAcrossLogicalReset - 60.0f) <= 1.0e-6f + && standaloneSnappedWithoutMorphAt60 + && staleTempoPreCallbackTail + 1.0e-5 + >= tenBpmPublishedTail + && hostResetPreCallbackTail + 1.0e-5 + >= tenBpmPublishedTail + && std::abs( + standaloneSnappedSamplesAt10 + - expectedTenBpmQuarterSamples) <= 1.0f + && standaloneSnappedWithoutMorphAt10 + && std::abs(tempoAfterHostReset) <= 1.0e-6f; + + CountingPlayHead rackHead(60.0); + S13NAMRack rack; + configureNeutralRack(rack); + rack.delayEnabled.store(1.0f); + rack.delayMix.store(0.75f); + rack.delayFeedback.store(0.0f); + rack.delayMod.store(0.0f); + rack.delayDucker.store(0.0f); + rack.delayMode.store(static_cast<float>( + S13NAMRack::digitalDelayMode)); + rack.delayTempoSync.store(1.0f); + rack.setPlayHead(&rackHead); + rack.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + const int rackCallsAfterPrepare = + rackHead.positionCalls; + const double rackUnknownTail = + rack.getTailLengthSeconds(); + const int rackCallsAfterInitialTail = + rackHead.positionCalls; + juce::AudioBuffer<float> rackBlock( + 2, fixtureBlockSize); + rackBlock.clear(); + rackBlock.setSample(0, 0, 0.5f); + rack.processBlock(rackBlock, midi); + const int rackCallsAfterProcess = + rackHead.positionCalls; + const double rackPublishedTail = + rack.getTailLengthSeconds(); + const int rackCallsAfterPublishedTail = + rackHead.positionCalls; + const float rackSnappedSamplesAt60 = + rack.rackDelay.requestedDelaySamplesL; + rackHead.bpm = 10.0; + rack.reset(); + const int rackCallsAfterHostReset = + rackHead.positionCalls; + const double rackStaleTempoPreCallbackTail = + rack.getTailLengthSeconds(); + const double maximumAutomatedDelayTail = + rack.getAutomatedTailLengthSeconds( + S13NAMRack::tailAutomationDelay); + const double actualQuarterWidth = 1.08; + const double actualQuarterTailAtTenBpm = + (6.0 + 1.0 / fixtureSampleRate) + * (std::log(0.001 / actualQuarterWidth) + / std::log(0.85) + + 1.0); + rackBlock.clear(); + rackBlock.setSample(0, 0, 0.5f); + rack.processBlock(rackBlock, midi); + const int rackCallsAfterTenBpmProcess = + rackHead.positionCalls; + const float rackSnappedSamplesAt10 = + rack.rackDelay.requestedDelaySamplesL; + const bool rackSnappedWithoutMorphAt10 = + ! rack.rackDelay.delayTimeMorphActive + && ! rack.rackDelay.delayTimeChangePending; + const double rackTenBpmPublishedTail = + rack.getTailLengthSeconds(); + const bool rackLifecyclePassed = + rackCallsAfterPrepare == 0 + && rackCallsAfterInitialTail == 0 + && rackCallsAfterProcess == 1 + && rackCallsAfterPublishedTail == 1 + && rackCallsAfterHostReset == 1 + && rackCallsAfterTenBpmProcess == 2 + && rackUnknownTail >= rackPublishedTail + && rackStaleTempoPreCallbackTail + + 0.020 + + 2.0 / fixtureSampleRate + >= rackTenBpmPublishedTail + && maximumAutomatedDelayTail + >= actualQuarterTailAtTenBpm + && std::abs( + rackSnappedSamplesAt60 + - expectedQuarterNoteSamples) <= 1.0f + && std::abs( + rackSnappedSamplesAt10 + - expectedTenBpmQuarterSamples) <= 1.0f + && rackSnappedWithoutMorphAt10 + && std::abs( + rack.publishedTempoBpm.load( + std::memory_order_relaxed) + - 10.0f) <= 1.0e-6f + && std::abs( + rack.rackDelay.publishedTempoBpm.load( + std::memory_order_relaxed) + - 10.0f) <= 1.0e-6f; + + CountingPlayHead maximumTailHead(10.0); + S13Delay maximumTailDelay; + maximumTailDelay.delayTimeL.store(1.0f); + maximumTailDelay.delayTimeR.store(1.0f); + maximumTailDelay.feedback.store(0.95f); + maximumTailDelay.crossFeed.store(0.0f); + maximumTailDelay.mix.store(1.0f); + maximumTailDelay.tempoSync.store(1.0f); + maximumTailDelay.syncNoteL.store(0.0f); + maximumTailDelay.syncNoteR.store(0.0f); + maximumTailDelay.lpfFreq.store(20000.0f); + maximumTailDelay.hpfFreq.store(20.0f); + maximumTailDelay.fbSaturation.store(0.0f); + maximumTailDelay.stereoWidth.store(2.0f); + maximumTailDelay.delayMode.store(0.0f); + maximumTailDelay.ducking.store(0.0f); + maximumTailDelay.setPlayHead(&maximumTailHead); + maximumTailDelay.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + juce::AudioBuffer<float> maximumTailBlock( + 2, fixtureBlockSize); + maximumTailBlock.clear(); + maximumTailBlock.setSample(0, 0, 1.0f); + maximumTailBlock.setSample(1, 0, -1.0f); + maximumTailDelay.processBlock(maximumTailBlock, midi); + const double maximumTailIntervals = + std::log(0.001 / 2.0) / std::log(0.95) + 1.0; + const double maximumTailExpectedBound = + (24.0 + 1.0 / fixtureSampleRate) + * maximumTailIntervals; + const double maximumTailPublishedBound = + static_cast<double>( + maximumTailDelay.publishedLiveTailSeconds.load( + std::memory_order_relaxed)); + maximumTailDelay.tempoSync.store(0.0f); + maximumTailDelay.delayTimeL.store(1.0f); + maximumTailDelay.delayTimeR.store(1.0f); + maximumTailDelay.feedback.store(0.0f); + maximumTailDelay.mix.store(0.0f); + maximumTailDelay.stereoWidth.store(1.0f); + const double maximumTailPreservedAfterDownwardEdit = + maximumTailDelay.getTailLengthSeconds(); + const bool maximumCapacityTailPassed = + maximumTailPublishedBound > 300.0 + && maximumTailPublishedBound + 0.01 + >= maximumTailExpectedBound + && maximumTailPreservedAfterDownwardEdit + 0.01 + >= maximumTailExpectedBound; + + auto* value = new juce::DynamicObject(); + value->setProperty( + "standalonePlayHeadCallsAfterPrepare", + callsAfterPrepare); + value->setProperty( + "standalonePlayHeadCallsAfterProcess", + callsAfterProcess); + value->setProperty( + "standaloneUnknownTempoTailSeconds", + conservativeUnknownTail); + value->setProperty( + "standalonePublishedTempoTailSeconds", + publishedTempoTail); + value->setProperty( + "standaloneTenBpmPublishedTailSeconds", + tenBpmPublishedTail); + value->setProperty( + "standaloneStaleTempoPreCallbackTailSeconds", + staleTempoPreCallbackTail); + value->setProperty( + "standaloneHostResetPreCallbackTailSeconds", + hostResetPreCallbackTail); + value->setProperty( + "standaloneSnappedSamplesAt60", + standaloneSnappedSamplesAt60); + value->setProperty( + "standaloneSnappedSamplesAt10", + standaloneSnappedSamplesAt10); + value->setProperty( + "standaloneLifecyclePassed", + standaloneLifecyclePassed); + value->setProperty( + "rackPlayHeadCallsAfterPrepare", + rackCallsAfterPrepare); + value->setProperty( + "rackPlayHeadCallsAfterProcess", + rackCallsAfterProcess); + value->setProperty( + "rackUnknownTempoTailSeconds", rackUnknownTail); + value->setProperty( + "rackPublishedTempoTailSeconds", rackPublishedTail); + value->setProperty( + "rackTenBpmPublishedTailSeconds", + rackTenBpmPublishedTail); + value->setProperty( + "rackStaleTempoPreCallbackTailSeconds", + rackStaleTempoPreCallbackTail); + value->setProperty( + "rackSnappedSamplesAt60", + rackSnappedSamplesAt60); + value->setProperty( + "rackSnappedSamplesAt10", + rackSnappedSamplesAt10); + value->setProperty( + "maximumAutomatedDelayTailSeconds", + maximumAutomatedDelayTail); + value->setProperty( + "actualQuarterTailAtTenBpmSeconds", + actualQuarterTailAtTenBpm); + value->setProperty( + "rackLifecyclePassed", rackLifecyclePassed); + value->setProperty( + "maximumTailExpectedBoundSeconds", + maximumTailExpectedBound); + value->setProperty( + "maximumTailPublishedBoundSeconds", + maximumTailPublishedBound); + value->setProperty( + "maximumTailPreservedAfterDownwardEditSeconds", + maximumTailPreservedAfterDownwardEdit); + value->setProperty( + "maximumCapacityTailPassed", + maximumCapacityTailPassed); + value->setProperty( + "pass", + standaloneLifecyclePassed + && rackLifecyclePassed + && maximumCapacityTailPassed); + return juce::var(value); +} + +juce::var NAMDelayRegression::runDelayTailLifecycleProbe() +{ + const int delaySamples = + juce::roundToInt(fixtureSampleRate * 0.25); + const int renderedSamples = delaySamples + 2048; + + auto configureDelay = [] (S13Delay& delay) + { + delay.delayTimeL.store(250.0f); + delay.delayTimeR.store(250.0f); + delay.feedback.store(0.0f); + delay.crossFeed.store(0.0f); + delay.mix.store(1.0f); + delay.pingPong.store(0.0f); + delay.tempoSync.store(0.0f); + delay.lpfFreq.store(20000.0f); + delay.hpfFreq.store(20.0f); + delay.fbSaturation.store(0.0f); + delay.stereoWidth.store(1.0f); + delay.delayMode.store(0.0f); + delay.ducking.store(0.0f); + }; + + auto renderSilenceAfterImpulse = [&] ( + S13Delay& delay, + bool resetAfterWriting, + bool preserveUnityDry) + { + juce::AudioBuffer<float> capture(2, renderedSamples); + capture.clear(); + juce::MidiBuffer midi; + int cursor = 0; + + juce::AudioBuffer<float> firstBlock(2, fixtureBlockSize); + firstBlock.clear(); + firstBlock.setSample(0, 0, 0.75f); + firstBlock.setSample(1, 0, -0.50f); + delay.processBlock(firstBlock, midi); + capture.copyFrom( + 0, 0, firstBlock, 0, 0, fixtureBlockSize); + capture.copyFrom( + 1, 0, firstBlock, 1, 0, fixtureBlockSize); + cursor += fixtureBlockSize; + + if (resetAfterWriting) + delay.resetTailState(); + delay.inputSend.store(0.0f); + delay.unityDry.store(preserveUnityDry ? 1.0f : 0.0f); + + while (cursor < renderedSamples) + { + const int blockSize = juce::jmin( + fixtureBlockSize, renderedSamples - cursor); + juce::AudioBuffer<float> block(2, blockSize); + block.clear(); + delay.processBlock(block, midi); + capture.copyFrom(0, cursor, block, 0, 0, blockSize); + capture.copyFrom(1, cursor, block, 1, 0, blockSize); + cursor += blockSize; + } + return capture; + }; + + S13Delay noSendDelay(1.0f); + configureDelay(noSendDelay); + noSendDelay.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + noSendDelay.inputSend.store(0.0f); + noSendDelay.unityDry.store(1.0f); + const auto noSendCapture = + renderSilenceAfterImpulse(noSendDelay, false, true); + float noSendEchoPeak = 0.0f; + for (int channel = 0; channel < 2; ++channel) + for (int sample = delaySamples - 8; + sample <= delaySamples + 8; + ++sample) + noSendEchoPeak = juce::jmax( + noSendEchoPeak, + std::abs(noSendCapture.getSample(channel, sample))); + + S13Delay resetDelay(1.0f); + configureDelay(resetDelay); + resetDelay.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + resetDelay.inputSend.store(1.0f); + resetDelay.unityDry.store(0.0f); + const auto resetCapture = + renderSilenceAfterImpulse(resetDelay, true, false); + float resetLeakPeak = 0.0f; + for (int channel = 0; channel < 2; ++channel) + for (int sample = fixtureBlockSize; + sample < resetCapture.getNumSamples(); + ++sample) + resetLeakPeak = juce::jmax( + resetLeakPeak, + std::abs(resetCapture.getSample(channel, sample))); + + auto* value = new juce::DynamicObject(); + value->setProperty("delaySamples", delaySamples); + value->setProperty("inputSendZeroEchoPeak", noSendEchoPeak); + value->setProperty("resetStaleHistoryPeak", resetLeakPeak); + value->setProperty( + "pass", + noSendEchoPeak <= 1.0e-7f + && resetLeakPeak <= 1.0e-7f); + return juce::var(value); +} + +juce::var NAMDelayRegression::runDelayHighFeedbackDecayProbe() +{ + constexpr double delaySampleRate = 44100.0; + constexpr int delayBlockSize = 16; + constexpr int stimulusBlocks = 1378; + constexpr int tailWindowBlocks = 1378; + constexpr int tailWindowCount = 8; + constexpr int stimulusSamples = + stimulusBlocks * delayBlockSize; + constexpr int tailWindowSamples = + tailWindowBlocks * delayBlockSize; + constexpr int totalBlocks = + stimulusBlocks + + tailWindowBlocks * tailWindowCount; + constexpr float feedbackAmount = 0.85f; + constexpr float characterAmount = 1.0f; + constexpr float maximumAcceptedPeak = 2.0f; + constexpr double maximumWindowGrowth = 1.15; + constexpr double maximumFinalToFirstRms = 0.10; + const int strumIntervalSamples = + juce::roundToInt(delaySampleRate * 0.075); + + auto runMode = [&] (int mode) + { + S13Delay delay(1.0f); + delay.setExtendedModesEnabled(true); + const auto state = + S13NAMRack::resolveDelayMacroState( + 37.0f, + feedbackAmount, + 1.0f, + characterAmount, + 0.0f, + static_cast<float>(mode), + 1.0f, + 0.0f, + S13NAMRack::guitarInstrumentProfile); + delay.delayTimeL.store( + state.timeMsL, std::memory_order_relaxed); + delay.delayTimeR.store( + state.timeMsR, + std::memory_order_relaxed); + delay.feedback.store( + state.feedbackGain, + std::memory_order_relaxed); + delay.crossFeed.store( + state.crossFeed, std::memory_order_relaxed); + delay.mix.store( + 1.0f, std::memory_order_relaxed); + delay.pingPong.store( + 1.0f, std::memory_order_relaxed); + delay.tempoSync.store( + 0.0f, std::memory_order_relaxed); + delay.lpfFreq.store( + state.lowPassHz, + std::memory_order_relaxed); + delay.hpfFreq.store( + state.highPassHz, std::memory_order_relaxed); + delay.fbSaturation.store( + state.saturation, + std::memory_order_relaxed); + delay.stereoWidth.store( + state.stereoWidth, std::memory_order_relaxed); + delay.delayMode.store( + static_cast<float>(mode), + std::memory_order_relaxed); + delay.ducking.store( + 0.0f, std::memory_order_relaxed); + delay.wowDepthMs.store( + state.wowDepthMs, std::memory_order_relaxed); + delay.wowRateHz.store( + state.wowRateHz, std::memory_order_relaxed); + delay.flutterDepthMs.store( + state.flutterDepthMs, std::memory_order_relaxed); + delay.flutterRateHz.store( + state.flutterRateHz, std::memory_order_relaxed); + delay.duckAttackMs.store( + state.duckAttackMs, std::memory_order_relaxed); + delay.duckReleaseMs.store( + state.duckReleaseMs, std::memory_order_relaxed); + delay.duckMaxReduction.store( + state.duckMaxReduction, std::memory_order_relaxed); + delay.topologyControl.store( + state.topologyControl, std::memory_order_relaxed); + delay.multiFeedback.store( + state.multiFeedbackGain, std::memory_order_relaxed); + delay.dualTimeRatio.store( + state.dualTimeRatio, std::memory_order_relaxed); + delay.dualFeedback.store( + state.dualFeedbackGain, std::memory_order_relaxed); + delay.dualLowPassHz.store( + state.dualLowPassHz, std::memory_order_relaxed); + delay.dualHighPassHz.store( + state.dualHighPassHz, std::memory_order_relaxed); + delay.dualSaturation.store( + state.dualSaturation, std::memory_order_relaxed); + delay.dualModDepthMs.store( + state.dualModDepthMs, std::memory_order_relaxed); + delay.dualModRateHz.store( + state.dualModRateHz, std::memory_order_relaxed); + delay.prepareToPlay( + delaySampleRate, delayBlockSize); + delay.inputSend.store( + 1.0f, std::memory_order_relaxed); + delay.unityDry.store( + 0.0f, std::memory_order_relaxed); + + std::array<double, 8> + tailEnergy {}; + std::array<int, 8> + tailValueCounts {}; + float inputPeak = 0.0f; + float outputPeak = 0.0f; + int nonFiniteCount = 0; + juce::AudioBuffer<float> block( + 2, delayBlockSize); + juce::MidiBuffer midi; + + for (int blockIndex = 0; + blockIndex < totalBlocks; + ++blockIndex) + { + block.clear(); + for (int sample = 0; + sample < delayBlockSize; + ++sample) + { + const int absoluteSample = + blockIndex * delayBlockSize + + sample; + if (absoluteSample >= stimulusSamples) + continue; + + const int strumSample = + absoluteSample + % strumIntervalSamples; + const int strumIndex = + absoluteSample + / strumIntervalSamples; + const double localTime = + static_cast<double>(strumSample) + / delaySampleRate; + const float envelope = + static_cast<float>( + std::exp(-localTime / 0.018)); + const double phase = + juce::MathConstants<double>::twoPi + * localTime; + const float polarity = + (strumIndex & 1) == 0 + ? 1.0f + : -1.0f; + const float attack = + strumSample == 0 + ? 0.34f * polarity + : 0.0f; + const float left = + attack + + envelope + * static_cast<float>( + 0.16 + * std::sin( + phase * 110.0) + + 0.10 + * std::sin( + phase * 329.63 + + 0.31) + + 0.06 + * std::sin( + phase * 987.77 + + 1.12)); + const float right = + -attack * 0.72f + + envelope + * static_cast<float>( + 0.13 + * std::sin( + phase * 146.83 + + 0.73) + + 0.09 + * std::sin( + phase * 440.0 + + 1.41) + + 0.05 + * std::sin( + phase * 1318.51 + + 2.07)); + block.setSample(0, sample, left); + block.setSample(1, sample, right); + inputPeak = juce::jmax( + inputPeak, + juce::jmax( + std::abs(left), + std::abs(right))); + } + + delay.processBlock(block, midi); + for (int channel = 0; + channel < 2; + ++channel) + { + const auto* samples = + block.getReadPointer(channel); + for (int sample = 0; + sample < delayBlockSize; + ++sample) + { + const float value = samples[sample]; + if (! std::isfinite(value)) + { + ++nonFiniteCount; + continue; + } + + outputPeak = juce::jmax( + outputPeak, + std::abs(value)); + const int absoluteSample = + blockIndex * delayBlockSize + + sample; + if (absoluteSample + < stimulusSamples) + { + continue; + } + + const int windowIndex = + (absoluteSample + - stimulusSamples) + / tailWindowSamples; + if (windowIndex + < tailWindowCount) + { + tailEnergy[ + static_cast<size_t>( + windowIndex)] + += static_cast<double>( + value) + * static_cast<double>( + value); + ++tailValueCounts[ + static_cast<size_t>( + windowIndex)]; + } + } + } + } + + std::array<double, 8> + tailRms {}; + juce::Array<juce::var> tailWindowRms; + for (size_t windowIndex = 0; + windowIndex < tailRms.size(); + ++windowIndex) + { + const int valueCount = + tailValueCounts[windowIndex]; + tailRms[windowIndex] = + valueCount > 0 + ? std::sqrt( + tailEnergy[windowIndex] + / static_cast<double>( + valueCount)) + : 0.0; + tailWindowRms.add( + tailRms[windowIndex]); + } + + bool nonGrowingDecay = true; + for (size_t windowIndex = 1; + windowIndex < tailRms.size(); + ++windowIndex) + { + const double allowedRms = + tailRms[windowIndex - 1] + * maximumWindowGrowth + + 1.0e-8; + if (tailRms[windowIndex] + > allowedRms) + { + nonGrowingDecay = false; + } + } + + const double firstTailRms = + tailRms.front(); + const double finalTailRms = + tailRms.back(); + const double finalToFirstRms = + firstTailRms > 0.0 + ? finalTailRms + / firstTailRms + : std::numeric_limits< + double>::infinity(); + const bool pass = + nonFiniteCount == 0 + && inputPeak > 0.1f + && outputPeak <= maximumAcceptedPeak + && firstTailRms > 1.0e-5 + && nonGrowingDecay + && finalToFirstRms + <= maximumFinalToFirstRms + && finalTailRms <= 1.0e-4; + + auto* value = + new juce::DynamicObject(); + value->setProperty( + "mode", + mode == S13NAMRack::digitalDelayMode + ? "Digital" + : mode == S13NAMRack::tapeDelayMode + ? "Tape" + : mode == S13NAMRack::analogDelayMode + ? "Analog" + : mode == S13NAMRack::multiDelayMode + ? "Multi" + : "Dual"); + value->setProperty( + "feedback", feedbackAmount); + value->setProperty( + "characterControl", + characterAmount); + value->setProperty( + "inputPeak", inputPeak); + value->setProperty( + "outputPeak", outputPeak); + value->setProperty( + "maximumAcceptedPeak", + maximumAcceptedPeak); + value->setProperty( + "tailWindowRms", tailWindowRms); + value->setProperty( + "finalToFirstTailRms", + finalToFirstRms); + value->setProperty( + "nonGrowingDecay", + nonGrowingDecay); + value->setProperty( + "nonFiniteCount", + nonFiniteCount); + value->setProperty("pass", pass); + return juce::var(value); + }; + + juce::Array<juce::var> cases; + bool allPass = true; + for (int mode = S13NAMRack::digitalDelayMode; + mode <= S13NAMRack::dualDelayMode; + ++mode) + { + const auto result = runMode(mode); + allPass = + allPass + && static_cast<bool>( + result.getProperty( + "pass", false)); + cases.add(result); + } + + auto* value = new juce::DynamicObject(); + value->setProperty( + "sampleRate", delaySampleRate); + value->setProperty( + "blockSize", delayBlockSize); + value->setProperty( + "stimulusSeconds", + static_cast<double>(stimulusSamples) + / delaySampleRate); + value->setProperty( + "silenceSeconds", + static_cast<double>( + tailWindowSamples + * tailWindowCount) + / delaySampleRate); + value->setProperty( + "cases", cases); + value->setProperty("pass", allPass); + return juce::var(value); +} + +juce::var NAMDelayRegression::runDelayFractionalResetProbe() +{ + constexpr double delaySampleRate = 44100.0; + constexpr int delayBlockSize = 16; + constexpr float requestedDelaySamples = 44.5f; + constexpr float historyMarker = 0.75f; + constexpr int prefillBlocks = 8; + constexpr int observationBlocks = 8; + const float delayMs = + requestedDelaySamples + * 1000.0f + / static_cast<float>( + delaySampleRate); + const int floorHistorySamples = + static_cast<int>( + std::floor(requestedDelaySamples)); + const int requiredHistorySamples = + static_cast<int>( + std::ceil(requestedDelaySamples)); + + S13Delay delay(0.1f); + delay.delayTimeL.store( + delayMs, std::memory_order_relaxed); + delay.delayTimeR.store( + delayMs, std::memory_order_relaxed); + delay.feedback.store( + 0.0f, std::memory_order_relaxed); + delay.crossFeed.store( + 0.0f, std::memory_order_relaxed); + delay.mix.store( + 1.0f, std::memory_order_relaxed); + delay.pingPong.store( + 0.0f, std::memory_order_relaxed); + delay.tempoSync.store( + 0.0f, std::memory_order_relaxed); + delay.lpfFreq.store( + 20000.0f, std::memory_order_relaxed); + delay.hpfFreq.store( + 20.0f, std::memory_order_relaxed); + delay.fbSaturation.store( + 0.0f, std::memory_order_relaxed); + delay.stereoWidth.store( + 1.0f, std::memory_order_relaxed); + delay.delayMode.store( + 0.0f, std::memory_order_relaxed); + delay.ducking.store( + 0.0f, std::memory_order_relaxed); + delay.prepareToPlay( + delaySampleRate, delayBlockSize); + delay.inputSend.store( + 1.0f, std::memory_order_relaxed); + delay.unityDry.store( + 0.0f, std::memory_order_relaxed); + + juce::AudioBuffer<float> block( + 2, delayBlockSize); + juce::MidiBuffer midi; + for (int blockIndex = 0; + blockIndex < prefillBlocks; + ++blockIndex) + { + for (int sample = 0; + sample < delayBlockSize; + ++sample) + { + block.setSample( + 0, sample, historyMarker); + block.setSample( + 1, sample, + -historyMarker * 0.8f); + } + delay.processBlock(block, midi); + } + + delay.inputSend.store( + 0.0f, std::memory_order_relaxed); + delay.unityDry.store( + 0.0f, std::memory_order_relaxed); + delay.resetTailState(); + + float staleHistoryPeak = 0.0f; + int peakSample = -1; + int nonFiniteCount = 0; + for (int blockIndex = 0; + blockIndex < observationBlocks; + ++blockIndex) + { + block.clear(); + delay.processBlock(block, midi); + for (int channel = 0; + channel < 2; + ++channel) + { + const auto* samples = + block.getReadPointer(channel); + for (int sample = 0; + sample < delayBlockSize; + ++sample) + { + const float value = samples[sample]; + if (! std::isfinite(value)) + { + ++nonFiniteCount; + continue; + } + + const float magnitude = + std::abs(value); + if (magnitude + > staleHistoryPeak) + { + staleHistoryPeak = + magnitude; + peakSample = + blockIndex + * delayBlockSize + + sample; + } + } + } + } + + const bool pass = + floorHistorySamples + < requiredHistorySamples + && nonFiniteCount == 0 + && staleHistoryPeak <= 1.0e-7f; + auto* value = new juce::DynamicObject(); + value->setProperty( + "sampleRate", delaySampleRate); + value->setProperty( + "blockSize", delayBlockSize); + value->setProperty( + "requestedDelaySamples", + requestedDelaySamples); + value->setProperty( + "floorHistorySamples", + floorHistorySamples); + value->setProperty( + "requiredHistorySamples", + requiredHistorySamples); + value->setProperty( + "prefillSamples", + prefillBlocks * delayBlockSize); + value->setProperty( + "observationSamples", + observationBlocks + * delayBlockSize); + value->setProperty( + "staleHistoryPeak", + staleHistoryPeak); + value->setProperty( + "peakSample", peakSample); + value->setProperty( + "nonFiniteCount", + nonFiniteCount); + value->setProperty("pass", pass); + return juce::var(value); +} + +juce::var NAMDelayRegression::runStandaloneDelayMalformedAndLegacyModeProbe() +{ + const double quietNaN = + std::numeric_limits<double>::quiet_NaN(); + const double positiveInfinity = + std::numeric_limits<double>::infinity(); + const double negativeInfinity = + -std::numeric_limits<double>::infinity(); + juce::ValueTree malformedTree("S13Delay"); + malformedTree.setProperty("delayTimeL", quietNaN, nullptr); + malformedTree.setProperty("delayTimeR", positiveInfinity, nullptr); + malformedTree.setProperty("feedback", negativeInfinity, nullptr); + malformedTree.setProperty("crossFeed", quietNaN, nullptr); + malformedTree.setProperty("mix", positiveInfinity, nullptr); + malformedTree.setProperty("pingPong", quietNaN, nullptr); + malformedTree.setProperty("tempoSync", 1.0, nullptr); + malformedTree.setProperty("syncNoteL", quietNaN, nullptr); + malformedTree.setProperty("syncNoteR", positiveInfinity, nullptr); + malformedTree.setProperty("lpfFreq", quietNaN, nullptr); + malformedTree.setProperty("hpfFreq", negativeInfinity, nullptr); + malformedTree.setProperty("fbSaturation", positiveInfinity, nullptr); + malformedTree.setProperty("stereoWidth", quietNaN, nullptr); + malformedTree.setProperty("delayMode", quietNaN, nullptr); + malformedTree.setProperty("ducking", positiveInfinity, nullptr); + juce::MemoryBlock malformedState; + { + juce::MemoryOutputStream stream(malformedState, false); + malformedTree.writeToStream(stream); + } + + S13Delay malformedDelay(3.0f); + malformedDelay.setStateInformation( + malformedState.getData(), + static_cast<int>(malformedState.getSize())); + const auto finiteAtomic = [] ( + const std::atomic<float>& value) + { + return std::isfinite( + value.load(std::memory_order_relaxed)); + }; + const bool restoredStateSanitized = + finiteAtomic(malformedDelay.delayTimeL) + && finiteAtomic(malformedDelay.delayTimeR) + && finiteAtomic(malformedDelay.feedback) + && finiteAtomic(malformedDelay.crossFeed) + && finiteAtomic(malformedDelay.mix) + && finiteAtomic(malformedDelay.pingPong) + && finiteAtomic(malformedDelay.tempoSync) + && finiteAtomic(malformedDelay.syncNoteL) + && finiteAtomic(malformedDelay.syncNoteR) + && finiteAtomic(malformedDelay.lpfFreq) + && finiteAtomic(malformedDelay.hpfFreq) + && finiteAtomic(malformedDelay.fbSaturation) + && finiteAtomic(malformedDelay.stereoWidth) + && finiteAtomic(malformedDelay.delayMode) + && finiteAtomic(malformedDelay.ducking) + && std::abs(malformedDelay.delayMode.load()) <= 1.0e-7f + && std::abs(malformedDelay.syncNoteL.load()) <= 1.0e-7f + && std::abs(malformedDelay.syncNoteR.load()) <= 1.0e-7f + && malformedDelay.tempoSync.load() >= 0.5f; + + const float floatNaN = + std::numeric_limits<float>::quiet_NaN(); + const float floatInfinity = + std::numeric_limits<float>::infinity(); + malformedDelay.wowDepthMs.store(floatNaN); + malformedDelay.wowRateHz.store(floatInfinity); + malformedDelay.flutterDepthMs.store(floatNaN); + malformedDelay.flutterRateHz.store(floatInfinity); + malformedDelay.duckAttackMs.store(floatNaN); + malformedDelay.duckReleaseMs.store(floatInfinity); + malformedDelay.duckMaxReduction.store(floatNaN); + malformedDelay.topologyControl.store(floatInfinity); + malformedDelay.multiFeedback.store(floatNaN); + malformedDelay.dualTimeRatio.store(floatInfinity); + malformedDelay.dualFeedback.store(floatNaN); + malformedDelay.dualLowPassHz.store(floatInfinity); + malformedDelay.dualHighPassHz.store(floatNaN); + malformedDelay.dualSaturation.store(floatInfinity); + malformedDelay.dualModDepthMs.store(floatNaN); + malformedDelay.dualModRateHz.store(floatInfinity); + constexpr double malformedSampleRate = 48000.0; + constexpr int malformedBlockSize = 64; + malformedDelay.prepareToPlay( + malformedSampleRate, malformedBlockSize); + + // Re-poison every callback-visible group after prepare so processBlock, + // tail reporting, and state export each prove their own sanitization. + malformedDelay.delayTimeL.store(floatNaN); + malformedDelay.delayTimeR.store(floatInfinity); + malformedDelay.feedback.store(floatNaN); + malformedDelay.crossFeed.store(floatInfinity); + malformedDelay.mix.store(floatNaN); + malformedDelay.pingPong.store(floatInfinity); + malformedDelay.tempoSync.store(1.0f); + malformedDelay.syncNoteL.store(floatNaN); + malformedDelay.syncNoteR.store(floatInfinity); + malformedDelay.lpfFreq.store(floatNaN); + malformedDelay.hpfFreq.store(floatInfinity); + malformedDelay.fbSaturation.store(floatNaN); + malformedDelay.stereoWidth.store(floatInfinity); + malformedDelay.delayMode.store(floatNaN); + malformedDelay.ducking.store(floatInfinity); + malformedDelay.wowDepthMs.store(floatNaN); + malformedDelay.wowRateHz.store(floatInfinity); + malformedDelay.flutterDepthMs.store(floatNaN); + malformedDelay.flutterRateHz.store(floatInfinity); + malformedDelay.duckAttackMs.store(floatNaN); + malformedDelay.duckReleaseMs.store(floatInfinity); + malformedDelay.duckMaxReduction.store(floatNaN); + malformedDelay.topologyControl.store(floatInfinity); + malformedDelay.multiFeedback.store(floatNaN); + malformedDelay.dualTimeRatio.store(floatInfinity); + malformedDelay.dualFeedback.store(floatNaN); + malformedDelay.dualLowPassHz.store(floatInfinity); + malformedDelay.dualHighPassHz.store(floatNaN); + malformedDelay.dualSaturation.store(floatInfinity); + malformedDelay.dualModDepthMs.store(floatNaN); + malformedDelay.dualModRateHz.store(floatInfinity); + malformedDelay.inputSend.store(floatNaN); + malformedDelay.unityDry.store(floatInfinity); + + constexpr int malformedRenderSamples = 101376; + int malformedNonFiniteCount = 0; + float malformedOutputPeak = 0.0f; + juce::MidiBuffer malformedMidi; + int malformedCursor = 0; + while (malformedCursor < malformedRenderSamples) + { + const int blockSamples = juce::jmin( + malformedBlockSize, + malformedRenderSamples - malformedCursor); + juce::AudioBuffer<float> block(2, blockSamples); + for (int sample = 0; sample < blockSamples; ++sample) + { + const int absoluteSample = malformedCursor + sample; + const float input = + (absoluteSample == 0 ? 0.40f : 0.0f) + + 0.03f * static_cast<float>(std::sin( + juce::MathConstants<double>::twoPi + * 173.0 + * static_cast<double>(absoluteSample) + / malformedSampleRate)); + block.setSample(0, sample, input); + block.setSample(1, sample, -input * 0.71f); + } + malformedDelay.processBlock(block, malformedMidi); + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; sample < blockSamples; ++sample) + { + const float output = block.getSample(channel, sample); + if (! std::isfinite(output)) + ++malformedNonFiniteCount; + else + malformedOutputPeak = juce::jmax( + malformedOutputPeak, + std::abs(output)); + } + } + malformedCursor += blockSamples; + } + const double malformedTailSeconds = + malformedDelay.getTailLengthSeconds(); + juce::MemoryBlock sanitizedState; + malformedDelay.getStateInformation(sanitizedState); + const auto sanitizedTree = juce::ValueTree::readFromData( + sanitizedState.getData(), sanitizedState.getSize()); + bool exportedStateFinite = sanitizedTree.isValid(); + if (sanitizedTree.isValid()) + { + for (int propertyIndex = 0; + propertyIndex < sanitizedTree.getNumProperties(); + ++propertyIndex) + { + exportedStateFinite = exportedStateFinite + && std::isfinite(static_cast<double>( + sanitizedTree.getProperty( + sanitizedTree.getPropertyName(propertyIndex)))); + } + } + const bool malformedRuntimePassed = + restoredStateSanitized + && malformedNonFiniteCount == 0 + && malformedOutputPeak <= 2.0f + && std::isfinite(malformedTailSeconds) + && malformedTailSeconds > 0.0 + && malformedTailSeconds <= 60.0 + && exportedStateFinite + && std::abs(static_cast<double>( + sanitizedTree.getProperty("delayMode", -1.0))) + <= 1.0e-7; + + const auto makeLegacyModeState = [] (float mode) + { + juce::ValueTree tree("S13Delay"); + tree.setProperty("delayTimeL", 37.0, nullptr); + tree.setProperty("delayTimeR", 37.0, nullptr); + tree.setProperty("feedback", 0.55, nullptr); + tree.setProperty("crossFeed", 0.0, nullptr); + tree.setProperty("mix", 1.0, nullptr); + tree.setProperty("pingPong", 0.0, nullptr); + tree.setProperty("tempoSync", 0.0, nullptr); + tree.setProperty("syncNoteL", 2.0, nullptr); + tree.setProperty("syncNoteR", 2.0, nullptr); + tree.setProperty("lpfFreq", 9200.0, nullptr); + tree.setProperty("hpfFreq", 75.0, nullptr); + tree.setProperty("fbSaturation", 0.40, nullptr); + tree.setProperty("stereoWidth", 1.0, nullptr); + tree.setProperty("delayMode", mode, nullptr); + tree.setProperty("ducking", 0.0, nullptr); + juce::MemoryBlock state; + { + juce::MemoryOutputStream stream(state, false); + tree.writeToStream(stream); + } + return state; + }; + const auto renderLegacyMode = [&makeLegacyModeState] (float mode) + { + constexpr double sampleRate = 48000.0; + constexpr int blockSize = 64; + constexpr int totalSamples = 48000; + S13Delay delay(1.0f); + const auto state = makeLegacyModeState(mode); + delay.setStateInformation( + state.getData(), static_cast<int>(state.getSize())); + delay.prepareToPlay(sampleRate, blockSize); + juce::AudioBuffer<float> capture(2, totalSamples); + capture.clear(); + juce::MidiBuffer midi; + int cursor = 0; + while (cursor < totalSamples) + { + const int blockSamples = juce::jmin( + blockSize, totalSamples - cursor); + juce::AudioBuffer<float> block(2, blockSamples); + for (int sample = 0; sample < blockSamples; ++sample) + { + const int absoluteSample = cursor + sample; + const float marker = absoluteSample % 997 == 0 + ? 0.30f + : 0.0f; + const double time = + static_cast<double>(absoluteSample) / sampleRate; + block.setSample( + 0, + sample, + marker + + 0.07f * static_cast<float>(std::sin( + juce::MathConstants<double>::twoPi + * 211.0 * time))); + block.setSample( + 1, + sample, + -marker * 0.61f + + 0.06f * static_cast<float>(std::sin( + juce::MathConstants<double>::twoPi + * 337.0 * time + 0.37))); + } + delay.processBlock(block, midi); + capture.copyFrom( + 0, cursor, block, 0, 0, blockSamples); + capture.copyFrom( + 1, cursor, block, 1, 0, blockSamples); + cursor += blockSamples; + } + return capture; + }; + + S13Delay legacyFractionalStateDelay(1.0f); + const auto legacyFractionalState = + makeLegacyModeState(1.9f); + legacyFractionalStateDelay.setStateInformation( + legacyFractionalState.getData(), + static_cast<int>(legacyFractionalState.getSize())); + juce::MemoryBlock legacyFractionalSavedState; + legacyFractionalStateDelay.getStateInformation( + legacyFractionalSavedState); + const auto legacyFractionalSavedTree = + juce::ValueTree::readFromData( + legacyFractionalSavedState.getData(), + legacyFractionalSavedState.getSize()); + const auto fractionalCapture = renderLegacyMode(1.9f); + const auto tapeCapture = renderLegacyMode(1.0f); + const auto analogCapture = renderLegacyMode(2.0f); + float fractionalTapeMaximumDifference = 0.0f; + double fractionalAnalogDifferenceEnergy = 0.0; + int fractionalAnalogValueCount = 0; + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; + sample < fractionalCapture.getNumSamples(); + ++sample) + { + fractionalTapeMaximumDifference = juce::jmax( + fractionalTapeMaximumDifference, + std::abs( + fractionalCapture.getSample(channel, sample) + - tapeCapture.getSample(channel, sample))); + const double difference = static_cast<double>( + fractionalCapture.getSample(channel, sample)) + - static_cast<double>( + analogCapture.getSample(channel, sample)); + fractionalAnalogDifferenceEnergy += + difference * difference; + ++fractionalAnalogValueCount; + } + } + const double fractionalAnalogDifferenceRms = + fractionalAnalogValueCount > 0 + ? std::sqrt( + fractionalAnalogDifferenceEnergy + / static_cast<double>(fractionalAnalogValueCount)) + : 0.0; + const bool legacyFractionalModePassed = + std::abs( + legacyFractionalStateDelay.delayMode.load() - 1.9f) + <= 1.0e-6f + && legacyFractionalSavedTree.isValid() + && std::abs(static_cast<double>( + legacyFractionalSavedTree.getProperty("delayMode")) + - 1.9) + <= 1.0e-6 + && fractionalTapeMaximumDifference <= 1.0e-7f + && fractionalAnalogDifferenceRms > 1.0e-5; + + auto* value = new juce::DynamicObject(); + value->setProperty( + "restoredMalformedStateSanitized", + restoredStateSanitized); + value->setProperty( + "malformedNonFiniteCount", + malformedNonFiniteCount); + value->setProperty( + "malformedOutputPeak", malformedOutputPeak); + value->setProperty( + "malformedTailSeconds", malformedTailSeconds); + value->setProperty( + "exportedMalformedStateFinite", + exportedStateFinite); + value->setProperty( + "legacyFractionalSavedMode", + legacyFractionalSavedTree.getProperty( + "delayMode", -1.0)); + value->setProperty( + "fractionalTapeMaximumDifference", + fractionalTapeMaximumDifference); + value->setProperty( + "fractionalAnalogDifferenceRms", + fractionalAnalogDifferenceRms); + value->setProperty( + "malformedRuntimePassed", malformedRuntimePassed); + value->setProperty( + "legacyFractionalModePassed", + legacyFractionalModePassed); + value->setProperty( + "pass", + malformedRuntimePassed && legacyFractionalModePassed); + return juce::var(value); +} + +juce::Array<juce::var> NAMDelayRegression::runLifecycleChecks() +{ + juce::Array<juce::var> checks; + + const auto delayPlayHeadLifecycleProbe = + runDelayPlayHeadLifecycleProbe(); + addCheck( + checks, + "delay_playhead_access_is_callback_only_and_sync_head_snaps", + delayPlayHeadLifecycleProbe.getProperty("pass", false) + ? "pass" + : "fail", + "Delay and NAM Rack lifecycle/tail APIs must never query the host playhead. A valid BPM is published only by processBlock, unknown-tempo tail reporting is conservative, and the first empty-history callback must snap sync heads to the actual tempo before accepting its transient.", + delayPlayHeadLifecycleProbe); + const auto delayTailLifecycleProbe = + runDelayTailLifecycleProbe(); + addCheck( + checks, + "delay_tail_input_send_and_reset_are_isolated", + delayTailLifecycleProbe.getProperty("pass", false) + ? "pass" + : "fail", + "Delay inputSend=0 must pass current dry input without recording a new echo, and resetTailState must prevent pre-reset ring contents from resurfacing.", + delayTailLifecycleProbe); + const auto delayHighFeedbackDecayProbe = + runDelayHighFeedbackDecayProbe(); + addCheck( + checks, + "delay_high_feedback_character_decay_is_bounded", + delayHighFeedbackDecayProbe.getProperty( + "pass", false) + ? "pass" + : "fail", + "At exact 44.1 kHz/16-sample callbacks, deterministic strums followed by silence must remain finite and bounded and decay without window-to-window growth in all five derived Digital, Tape, Analog, Multi, and Dual modes at maximum visible Feedback and Modulation.", + delayHighFeedbackDecayProbe); + const auto delayFractionalResetProbe = + runDelayFractionalResetProbe(); + addCheck( + checks, + "delay_fractional_reset_requires_complete_interpolation_history", + delayFractionalResetProbe.getProperty( + "pass", false) + ? "pass" + : "fail", + "After a logical reset, a 44.5-sample linear-interpolation tap must stay muted until both source samples are post-reset; using floor(delay) for the history gate would expose one stale pre-reset marker sample.", + delayFractionalResetProbe); + const auto standaloneDelayMalformedAndLegacyModeProbe = + runStandaloneDelayMalformedAndLegacyModeProbe(); + addCheck( + checks, + "standalone_delay_malformed_state_and_legacy_fractional_mode", + standaloneDelayMalformedAndLegacyModeProbe.getProperty( + "pass", false) + ? "pass" + : "fail", + "Standalone Delay must sanitize malformed persisted and live NaN/Inf parameters across prepare, tempo-sync processing, tail reporting and state export, while retaining the historical fractional-mode contract in which 1.9 remains serialized as 1.9 but processes exactly as truncated Tape rather than rounded Analog.", + standaloneDelayMalformedAndLegacyModeProbe); + + return checks; +} diff --git a/Source/NAMDelayRegressionRackTail.cpp b/Source/NAMDelayRegressionRackTail.cpp new file mode 100644 index 0000000..159e9a9 --- /dev/null +++ b/Source/NAMDelayRegressionRackTail.cpp @@ -0,0 +1,1188 @@ +#include "NAMDelayRegression.h" +#include "BuiltInEffects2.h" +#include "TrackProcessor.h" + +#include <algorithm> +#include <array> +#include <atomic> +#include <cmath> +#include <cstdint> +#include <limits> +#include <memory> +#include <numeric> +#include <utility> +#include <vector> + +juce::var NAMDelayRegression::runRackDelaySpilloverProbe() +{ + S13NAMRack spillRack; + S13NAMRack referenceRack; + spillRack.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + referenceRack.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + configureNeutralRack(spillRack); + configureNeutralRack(referenceRack); + + spillRack.delayEnabled.store(1.0f); + spillRack.delayMix.store(0.8f); + spillRack.delayTimeMs.store(20.0f); + spillRack.delayFeedback.store(0.0f); + spillRack.delayMod.store(0.0f); + spillRack.delayDucker.store(0.0f); + spillRack.delayMode.store(0.0f); + spillRack.delayPingPong.store(0.0f); + spillRack.delayTempoSync.store(0.0f); + + juce::MidiBuffer midi; + for (int warmup = 0; warmup < 64; ++warmup) + { + juce::AudioBuffer<float> spillBlock( + 2, fixtureBlockSize); + juce::AudioBuffer<float> referenceBlock( + 2, fixtureBlockSize); + spillBlock.clear(); + referenceBlock.clear(); + spillRack.processBlock(spillBlock, midi); + referenceRack.processBlock(referenceBlock, midi); + } + + juce::AudioBuffer<float> spillImpulse( + 2, fixtureBlockSize); + juce::AudioBuffer<float> referenceImpulse( + 2, fixtureBlockSize); + spillImpulse.clear(); + referenceImpulse.clear(); + spillImpulse.setSample(0, 0, 0.65f); + spillImpulse.setSample(1, 0, -0.45f); + referenceImpulse.makeCopyOf(spillImpulse, true); + spillRack.processBlock(spillImpulse, midi); + referenceRack.processBlock(referenceImpulse, midi); + spillRack.delayEnabled.store(0.0f); + + float spillPeak = 0.0f; + float lateDifferencePeak = 0.0f; + constexpr int bypassBlocks = 6; + for (int blockIndex = 0; + blockIndex < bypassBlocks; + ++blockIndex) + { + juce::AudioBuffer<float> spillBlock( + 2, fixtureBlockSize); + juce::AudioBuffer<float> referenceBlock( + 2, fixtureBlockSize); + for (int sample = 0; + sample < fixtureBlockSize; + ++sample) + { + const int absoluteSample = + blockIndex * fixtureBlockSize + sample; + const float marker = + 0.03f * std::sin( + juce::MathConstants<float>::twoPi + * 617.0f + * static_cast<float>(absoluteSample) + / static_cast<float>(fixtureSampleRate)); + spillBlock.setSample(0, sample, marker); + spillBlock.setSample(1, sample, -marker * 0.7f); + referenceBlock.setSample(0, sample, marker); + referenceBlock.setSample(1, sample, -marker * 0.7f); + } + + spillRack.processBlock(spillBlock, midi); + referenceRack.processBlock(referenceBlock, midi); + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; + sample < fixtureBlockSize; + ++sample) + { + const float difference = std::abs( + spillBlock.getSample(channel, sample) + - referenceBlock.getSample(channel, sample)); + if (blockIndex < 2) + spillPeak = juce::jmax( + spillPeak, difference); + if (blockIndex >= 3) + lateDifferencePeak = juce::jmax( + lateDifferencePeak, difference); + } + } + } + + auto* value = new juce::DynamicObject(); + value->setProperty("spillPeak", spillPeak); + value->setProperty( + "lateDifferencePeak", lateDifferencePeak); + value->setProperty("bypassBlocks", bypassBlocks); + value->setProperty( + "pass", + spillPeak > 1.0e-4f + && lateDifferencePeak <= 1.0e-6f); + return juce::var(value); +} + +juce::var NAMDelayRegression::runRackDelayV10FrozenTailAndBudgetProbe() +{ + const auto configureFrozenDualRack = [&] ( + S13NAMRack& rack) + { + configureNeutralRack(rack); + rack.instrumentProfile.store( + static_cast<float>( + S13NAMRack::guitarInstrumentProfile)); + rack.delayEnabled.store(1.0f); + rack.delayTimeMs.store(40.0f); + rack.delayFeedback.store(0.55f); + rack.delayMix.store(0.80f); + rack.delayMod.store(0.72f); + rack.delayDucker.store(0.30f); + rack.delayMode.store(static_cast<float>( + S13NAMRack::dualDelayMode)); + rack.delayPingPong.store(1.0f); + rack.delayTempoSync.store(0.0f); + rack.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + }; + + S13NAMRack frozenReferenceRack; + S13NAMRack frozenEditedRack; + configureFrozenDualRack(frozenReferenceRack); + configureFrozenDualRack(frozenEditedRack); + juce::MidiBuffer frozenMidi; + juce::AudioBuffer<float> referenceImpulse( + 2, fixtureBlockSize); + juce::AudioBuffer<float> editedImpulse( + 2, fixtureBlockSize); + referenceImpulse.clear(); + editedImpulse.clear(); + referenceImpulse.setSample(0, 0, 0.72f); + referenceImpulse.setSample(1, 9, -0.53f); + editedImpulse.makeCopyOf(referenceImpulse, true); + frozenReferenceRack.processDelayStage( + referenceImpulse, frozenMidi); + frozenEditedRack.processDelayStage( + editedImpulse, frozenMidi); + frozenReferenceRack.delayEnabled.store(0.0f); + frozenEditedRack.delayEnabled.store(0.0f); + + // Every visible source feeding DelayMacroState is deliberately changed + // after bypass. A partial freeze would now retarget at least one delay + // history, feedback/filter law, topology, sync route, or dry/wet law. + frozenEditedRack.instrumentProfile.store( + static_cast<float>(S13NAMRack::bassInstrumentProfile)); + frozenEditedRack.delayTimeMs.store(777.0f); + frozenEditedRack.delayFeedback.store(0.05f); + frozenEditedRack.delayMix.store(0.20f); + frozenEditedRack.delayMod.store(0.02f); + frozenEditedRack.delayDucker.store(0.90f); + frozenEditedRack.delayMode.store(static_cast<float>( + S13NAMRack::multiDelayMode)); + frozenEditedRack.delayPingPong.store(0.0f); + frozenEditedRack.delayTempoSync.store(1.0f); + + const auto frozenInitialTailBudget = std::max( + frozenReferenceRack.delayTailSamplesRemaining, + frozenEditedRack.delayTailSamplesRemaining); + const int drainBlocks = juce::jmax( + 1, + static_cast<int>( + (frozenInitialTailBudget + + static_cast<std::int64_t>(fixtureBlockSize) - 1) + / static_cast<std::int64_t>(fixtureBlockSize)) + + 1); + double frozenTailEnergy = 0.0; + float frozenTailMaximumDifference = 0.0f; + int frozenTailNonFiniteCount = 0; + for (int blockIndex = 0; + blockIndex < drainBlocks; + ++blockIndex) + { + juce::AudioBuffer<float> referenceBlock( + 2, fixtureBlockSize); + juce::AudioBuffer<float> editedBlock( + 2, fixtureBlockSize); + referenceBlock.clear(); + editedBlock.clear(); + frozenReferenceRack.processDelayStage( + referenceBlock, frozenMidi); + frozenEditedRack.processDelayStage( + editedBlock, frozenMidi); + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; + sample < fixtureBlockSize; + ++sample) + { + const float reference = + referenceBlock.getSample(channel, sample); + const float edited = + editedBlock.getSample(channel, sample); + if (! std::isfinite(reference) + || ! std::isfinite(edited)) + { + ++frozenTailNonFiniteCount; + continue; + } + frozenTailEnergy += + static_cast<double>(reference) + * static_cast<double>(reference); + frozenTailMaximumDifference = juce::jmax( + frozenTailMaximumDifference, + std::abs(reference - edited)); + } + } + } + const bool frozenTailPassed = + frozenTailNonFiniteCount == 0 + && frozenTailEnergy > 1.0e-5 + && frozenTailMaximumDifference <= 2.0e-6f + && frozenReferenceRack.delayTailSamplesRemaining == 0 + && frozenEditedRack.delayTailSamplesRemaining == 0 + && frozenReferenceRack.getTailLengthSeconds() <= 1.0e-9 + && frozenEditedRack.getTailLengthSeconds() <= 1.0e-9 + && ! frozenReferenceRack.delayTailMacroValid + && ! frozenEditedRack.delayTailMacroValid; + + struct DownwardBudgetResult + { + std::int64_t initialBudget = 0; + std::int64_t initialReportedBudget = 0; + std::int64_t initialPublicBudget = 0; + std::int64_t elapsedPriorBudget = 0; + std::int64_t elapsedPriorReportedBudget = 0; + std::int64_t loweredBudget = 0; + std::int64_t lowerReportedBudget = 0; + std::int64_t lowerPublicBudget = 0; + std::int64_t bypassBudget = 0; + std::int64_t bypassPublicBudget = 0; + std::int64_t expectedBypassBudget = 0; + bool cachedLowerMacro = false; + bool pass = false; + }; + const auto runDownwardBudgetCase = [&] ( + float initialTimeMs, + float loweredTimeMs, + float initialFeedback, + float loweredFeedback, + float initialMix, + float loweredMix) + { + S13NAMRack rack; + configureNeutralRack(rack); + rack.instrumentProfile.store(static_cast<float>( + S13NAMRack::guitarInstrumentProfile)); + rack.delayEnabled.store(1.0f); + rack.delayMix.store(initialMix); + rack.delayMod.store(0.0f); + rack.delayDucker.store(0.0f); + rack.delayMode.store(static_cast<float>( + S13NAMRack::digitalDelayMode)); + rack.delayPingPong.store(0.0f); + rack.delayTempoSync.store(0.0f); + rack.delayTimeMs.store(initialTimeMs); + rack.delayFeedback.store(initialFeedback); + rack.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + + juce::MidiBuffer midi; + juce::AudioBuffer<float> activeBlock( + 2, fixtureBlockSize); + activeBlock.clear(); + activeBlock.setSample(0, 0, 0.61f); + activeBlock.setSample(1, 5, -0.44f); + rack.processDelayStage(activeBlock, midi); + DownwardBudgetResult result; + result.initialBudget = + rack.delayTailSamplesRemaining; + result.initialReportedBudget = + static_cast<std::int64_t>(std::ceil( + rack.rackDelay.getTailLengthSeconds() + * fixtureSampleRate)); + result.initialPublicBudget = + static_cast<std::int64_t>(std::ceil( + rack.getTailLengthSeconds() + * fixtureSampleRate)); + + rack.delayTimeMs.store(loweredTimeMs); + rack.delayFeedback.store(loweredFeedback); + rack.delayMix.store(loweredMix); + activeBlock.clear(); + rack.processDelayStage(activeBlock, midi); + result.loweredBudget = + rack.delayTailSamplesRemaining; + result.lowerReportedBudget = + static_cast<std::int64_t>(std::ceil( + rack.rackDelay.getTailLengthSeconds() + * fixtureSampleRate)); + result.lowerPublicBudget = + static_cast<std::int64_t>(std::ceil( + rack.getTailLengthSeconds() + * fixtureSampleRate)); + const auto loweredMacro = + S13NAMRack::resolveDelayMacroState( + loweredTimeMs, + loweredFeedback, + loweredMix, + 0.0f, + 0.0f, + static_cast<float>( + S13NAMRack::digitalDelayMode), + 0.0f, + 0.0f, + S13NAMRack::guitarInstrumentProfile); + result.cachedLowerMacro = + rack.delayTailMacroValid + && std::abs( + rack.delayTailMacro.timeMsL + - loweredMacro.timeMsL) <= 1.0e-6f + && std::abs( + rack.delayTailMacro.feedbackGain + - loweredMacro.feedbackGain) <= 1.0e-6f + && std::abs( + rack.delayTailMacro.mix + - loweredMacro.mix) <= 1.0e-6f; + + rack.delayEnabled.store(0.0f); + juce::AudioBuffer<float> bypassBlock( + 2, fixtureBlockSize); + bypassBlock.clear(); + rack.processDelayStage(bypassBlock, midi); + result.bypassBudget = + rack.delayTailSamplesRemaining; + result.bypassPublicBudget = + static_cast<std::int64_t>(std::ceil( + rack.getTailLengthSeconds() + * fixtureSampleRate)); + result.elapsedPriorBudget = + juce::jmax<std::int64_t>( + 0, + result.initialBudget + - static_cast<std::int64_t>( + fixtureBlockSize)); + result.elapsedPriorReportedBudget = + juce::jmax<std::int64_t>( + 0, + result.initialReportedBudget + - static_cast<std::int64_t>( + fixtureBlockSize)); + result.expectedBypassBudget = + juce::jmax<std::int64_t>( + 0, + result.loweredBudget + - static_cast<std::int64_t>( + fixtureBlockSize)); + result.pass = + result.initialBudget + > static_cast<std::int64_t>(fixtureBlockSize) + && result.initialReportedBudget + > static_cast<std::int64_t>(fixtureBlockSize) + && result.lowerReportedBudget + 2 + >= result.elapsedPriorReportedBudget + && result.loweredBudget + >= result.elapsedPriorBudget + && result.initialPublicBudget + 2 + >= result.initialBudget + && result.lowerPublicBudget + 2 + >= result.loweredBudget + && result.bypassBudget + == result.expectedBypassBudget + && result.bypassPublicBudget + 2 + >= result.bypassBudget + && result.cachedLowerMacro; + return result; + }; + const auto downwardFeedbackBudget = + runDownwardBudgetCase( + 40.0f, 40.0f, + 0.85f, 0.0f, + 0.75f, 0.75f); + const auto downwardTimeBudget = + runDownwardBudgetCase( + 2000.0f, 1.0f, + 0.0f, 0.0f, + 0.75f, 0.75f); + const auto downwardMixBudget = + runDownwardBudgetCase( + 180.0f, 180.0f, + 0.65f, 0.65f, + 0.90f, 0.05f); + const bool downwardAutomationBudgetPassed = + downwardFeedbackBudget.pass + && downwardTimeBudget.pass + && downwardMixBudget.pass; + + S13NAMRack sendReleaseHorizonRack; + configureNeutralRack(sendReleaseHorizonRack); + sendReleaseHorizonRack.delayEnabled.store(1.0f); + sendReleaseHorizonRack.delayMix.store(1.0f); + sendReleaseHorizonRack.delayTimeMs.store(2000.0f); + sendReleaseHorizonRack.delayFeedback.store(0.0f); + sendReleaseHorizonRack.delayMod.store(0.0f); + sendReleaseHorizonRack.delayDucker.store(0.0f); + sendReleaseHorizonRack.delayMode.store(static_cast<float>( + S13NAMRack::digitalDelayMode)); + sendReleaseHorizonRack.delayPingPong.store(0.0f); + sendReleaseHorizonRack.delayTempoSync.store(0.0f); + sendReleaseHorizonRack.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + juce::AudioBuffer<float> horizonActiveBlock( + 2, fixtureBlockSize); + for (int channel = 0; channel < 2; ++channel) + horizonActiveBlock.clear(channel, 0, fixtureBlockSize); + for (int sample = 0; + sample < fixtureBlockSize; + ++sample) + { + horizonActiveBlock.setSample(0, sample, 0.50f); + horizonActiveBlock.setSample(1, sample, -0.35f); + } + sendReleaseHorizonRack.processDelayStage( + horizonActiveBlock, frozenMidi); + const auto sendReleaseInitialBudget = + sendReleaseHorizonRack.delayTailSamplesRemaining; + const auto minimumSendReleaseBudget = + static_cast<std::int64_t>(std::ceil( + fixtureSampleRate * 2.020)); + sendReleaseHorizonRack.delayEnabled.store(0.0f); + const int releaseObservationStart = + static_cast<int>(std::ceil( + fixtureSampleRate * 2.008)); + const int releaseObservationEnd = + static_cast<int>(std::ceil( + fixtureSampleRate * 2.012)); + float sendReleaseLateEchoPeak = 0.0f; + int releaseCursor = 0; + while (releaseCursor < releaseObservationEnd) + { + juce::AudioBuffer<float> horizonBlock( + 2, fixtureBlockSize); + horizonBlock.clear(); + sendReleaseHorizonRack.processDelayStage( + horizonBlock, frozenMidi); + for (int sample = 0; + sample < fixtureBlockSize; + ++sample) + { + const int absoluteSample = releaseCursor + sample; + if (absoluteSample < releaseObservationStart + || absoluteSample >= releaseObservationEnd) + { + continue; + } + sendReleaseLateEchoPeak = juce::jmax( + sendReleaseLateEchoPeak, + juce::jmax( + std::abs(horizonBlock.getSample(0, sample)), + std::abs(horizonBlock.getSample(1, sample)))); + } + releaseCursor += fixtureBlockSize; + } + const auto sendReleaseRemainingBudget = + sendReleaseHorizonRack.delayTailSamplesRemaining; + const auto expectedSendReleaseRemainingBudget = + juce::jmax<std::int64_t>( + 0, + sendReleaseInitialBudget + - static_cast<std::int64_t>(releaseCursor)); + const bool sendReleaseHorizonPassed = + sendReleaseInitialBudget >= minimumSendReleaseBudget + && sendReleaseRemainingBudget + == expectedSendReleaseRemainingBudget + && sendReleaseLateEchoPeak > 1.0e-4f; + + S13NAMRack rapidRetargetRack; + configureNeutralRack(rapidRetargetRack); + rapidRetargetRack.delayEnabled.store(1.0f); + rapidRetargetRack.delayMix.store(1.0f); + rapidRetargetRack.delayTimeMs.store(100.0f); + rapidRetargetRack.delayFeedback.store(0.0f); + rapidRetargetRack.delayMod.store(0.0f); + rapidRetargetRack.delayDucker.store(0.0f); + rapidRetargetRack.delayMode.store(static_cast<float>( + S13NAMRack::digitalDelayMode)); + rapidRetargetRack.delayPingPong.store(0.0f); + rapidRetargetRack.delayTempoSync.store(0.0f); + rapidRetargetRack.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + juce::AudioBuffer<float> retargetBlock( + 2, fixtureBlockSize); + retargetBlock.clear(); + retargetBlock.setSample(0, 0, 0.5f); + rapidRetargetRack.processDelayStage( + retargetBlock, frozenMidi); + rapidRetargetRack.delayTimeMs.store(1000.0f); + retargetBlock.clear(); + rapidRetargetRack.processDelayStage( + retargetBlock, frozenMidi); + rapidRetargetRack.delayTimeMs.store(1500.0f); + retargetBlock.clear(); + rapidRetargetRack.processDelayStage( + retargetBlock, frozenMidi); + const bool rapidRetargetStatesArmed = + rapidRetargetRack.rackDelay.delayTimeMorphActive + && rapidRetargetRack.rackDelay.delayTimeChangePending; + const auto rapidRetargetBudget = + rapidRetargetRack.delayTailSamplesRemaining; + const auto minimumRapidRetargetBudget = + static_cast<std::int64_t>(std::ceil( + fixtureSampleRate * (1.5 + 0.03 + 0.03 + 0.02))); + rapidRetargetRack.delayEnabled.store(0.0f); + retargetBlock.clear(); + rapidRetargetRack.processDelayStage( + retargetBlock, frozenMidi); + const auto rapidRetargetBypassBudget = + rapidRetargetRack.delayTailSamplesRemaining; + const bool rapidRetargetTailPassed = + rapidRetargetStatesArmed + && rapidRetargetBudget >= minimumRapidRetargetBudget + && rapidRetargetBypassBudget + == rapidRetargetBudget + - static_cast<std::int64_t>(fixtureBlockSize); + + const auto budgetToVar = [] ( + const DownwardBudgetResult& result) + { + auto* value = new juce::DynamicObject(); + value->setProperty( + "initialBudgetSamples", + static_cast<juce::int64>(result.initialBudget)); + value->setProperty( + "initialReportedBudgetSamples", + static_cast<juce::int64>( + result.initialReportedBudget)); + value->setProperty( + "initialPublicBudgetSamples", + static_cast<juce::int64>( + result.initialPublicBudget)); + value->setProperty( + "elapsedPriorBudgetSamples", + static_cast<juce::int64>( + result.elapsedPriorBudget)); + value->setProperty( + "elapsedPriorReportedBudgetSamples", + static_cast<juce::int64>( + result.elapsedPriorReportedBudget)); + value->setProperty( + "loweredBudgetSamples", + static_cast<juce::int64>(result.loweredBudget)); + value->setProperty( + "lowerReportedBudgetSamples", + static_cast<juce::int64>(result.lowerReportedBudget)); + value->setProperty( + "lowerPublicBudgetSamples", + static_cast<juce::int64>(result.lowerPublicBudget)); + value->setProperty( + "firstBypassBudgetSamples", + static_cast<juce::int64>(result.bypassBudget)); + value->setProperty( + "firstBypassPublicBudgetSamples", + static_cast<juce::int64>( + result.bypassPublicBudget)); + value->setProperty( + "expectedFirstBypassBudgetSamples", + static_cast<juce::int64>( + result.expectedBypassBudget)); + value->setProperty( + "cachedLowerMacro", result.cachedLowerMacro); + value->setProperty("pass", result.pass); + return juce::var(value); + }; + + auto* value = new juce::DynamicObject(); + value->setProperty( + "frozenTailEnergy", frozenTailEnergy); + value->setProperty( + "frozenInitialTailBudgetSamples", + static_cast<juce::int64>(frozenInitialTailBudget)); + value->setProperty( + "frozenTailMaximumDifference", + frozenTailMaximumDifference); + value->setProperty( + "frozenTailNonFiniteCount", + frozenTailNonFiniteCount); + value->setProperty( + "frozenTailPassed", frozenTailPassed); + value->setProperty( + "downwardFeedback", budgetToVar( + downwardFeedbackBudget)); + value->setProperty( + "downwardTime", budgetToVar( + downwardTimeBudget)); + value->setProperty( + "downwardMix", budgetToVar( + downwardMixBudget)); + value->setProperty( + "downwardAutomationBudgetPassed", + downwardAutomationBudgetPassed); + value->setProperty( + "sendReleaseInitialBudgetSamples", + static_cast<juce::int64>(sendReleaseInitialBudget)); + value->setProperty( + "minimumSendReleaseBudgetSamples", + static_cast<juce::int64>(minimumSendReleaseBudget)); + value->setProperty( + "sendReleaseRemainingBudgetSamples", + static_cast<juce::int64>(sendReleaseRemainingBudget)); + value->setProperty( + "expectedSendReleaseRemainingBudgetSamples", + static_cast<juce::int64>(expectedSendReleaseRemainingBudget)); + value->setProperty( + "sendReleaseLateEchoPeak", sendReleaseLateEchoPeak); + value->setProperty( + "sendReleaseHorizonPassed", sendReleaseHorizonPassed); + value->setProperty( + "rapidRetargetBudgetSamples", + static_cast<juce::int64>(rapidRetargetBudget)); + value->setProperty( + "minimumRapidRetargetBudgetSamples", + static_cast<juce::int64>(minimumRapidRetargetBudget)); + value->setProperty( + "rapidRetargetFirstBypassBudgetSamples", + static_cast<juce::int64>(rapidRetargetBypassBudget)); + value->setProperty( + "rapidRetargetStatesArmed", rapidRetargetStatesArmed); + value->setProperty( + "rapidRetargetTailPassed", rapidRetargetTailPassed); + value->setProperty( + "pass", + frozenTailPassed + && downwardAutomationBudgetPassed + && sendReleaseHorizonPassed + && rapidRetargetTailPassed); + return juce::var(value); +} + +juce::var NAMDelayRegression::runRackMinimumDelayBypassProbe() +{ + constexpr float minimumDelayMs = 1.0f; + constexpr float delayWetMix = 0.80f; + constexpr float constantInput = 0.50f; + constexpr int warmupBlocks = 64; + constexpr int observationBlocks = 4; + + auto configureMinimumDelay = + [&] (S13NAMRack& rack) + { + configureNeutralRack(rack); + rack.delayEnabled.store(1.0f); + rack.delayMix.store(delayWetMix); + rack.delayTimeMs.store(minimumDelayMs); + rack.delayFeedback.store(0.0f); + rack.delayMod.store(0.0f); + rack.delayDucker.store(0.0f); + rack.delayMode.store(0.0f); + rack.delayPingPong.store(0.0f); + rack.delayTempoSync.store(0.0f); + }; + auto prepareRack = [&] ( + S13NAMRack& rack, + bool withDelay) + { + if (withDelay) + configureMinimumDelay(rack); + else + configureNeutralRack(rack); + rack.prepareToPlay( + fixtureSampleRate, fixtureBlockSize); + }; + auto warmSilence = [&] ( + S13NAMRack& rack, + juce::MidiBuffer& midi) + { + for (int blockIndex = 0; + blockIndex < warmupBlocks; + ++blockIndex) + { + juce::AudioBuffer<float> block( + 2, fixtureBlockSize); + block.clear(); + rack.processBlock(block, midi); + } + }; + + S13NAMRack fadeRack; + S13NAMRack fadeReferenceRack; + prepareRack(fadeRack, true); + prepareRack(fadeReferenceRack, false); + juce::MidiBuffer midi; + warmSilence(fadeRack, midi); + warmSilence(fadeReferenceRack, midi); + fadeRack.delayEnabled.store(0.0f); + + const int observationSamples = + observationBlocks * fixtureBlockSize; + juce::AudioBuffer<float> fadeCapture( + 2, observationSamples); + juce::AudioBuffer<float> fadeReferenceCapture( + 2, observationSamples); + fadeCapture.clear(); + fadeReferenceCapture.clear(); + for (int blockIndex = 0; + blockIndex < observationBlocks; + ++blockIndex) + { + juce::AudioBuffer<float> block( + 2, fixtureBlockSize); + juce::AudioBuffer<float> referenceBlock( + 2, fixtureBlockSize); + for (int sample = 0; + sample < fixtureBlockSize; + ++sample) + { + block.setSample(0, sample, constantInput); + block.setSample(1, sample, -constantInput * 0.70f); + referenceBlock.setSample( + 0, sample, constantInput); + referenceBlock.setSample( + 1, sample, -constantInput * 0.70f); + } + fadeRack.processBlock(block, midi); + fadeReferenceRack.processBlock( + referenceBlock, midi); + fadeCapture.copyFrom( + 0, + blockIndex * fixtureBlockSize, + block, + 0, + 0, + fixtureBlockSize); + fadeCapture.copyFrom( + 1, + blockIndex * fixtureBlockSize, + block, + 1, + 0, + fixtureBlockSize); + fadeReferenceCapture.copyFrom( + 0, + blockIndex * fixtureBlockSize, + referenceBlock, + 0, + 0, + fixtureBlockSize); + fadeReferenceCapture.copyFrom( + 1, + blockIndex * fixtureBlockSize, + referenceBlock, + 1, + 0, + fixtureBlockSize); + } + + float maximumRatioStep = 0.0f; + float firstRatio = 0.0f; + float tenMillisecondRatio = 0.0f; + float thirtyMillisecondError = 1.0f; + int firstMeasuredSample = -1; + int firstUnitySample = -1; + float previousRatio = 0.0f; + bool havePreviousRatio = false; + const int tenMillisecondSample = + juce::roundToInt( + static_cast<float>(fixtureSampleRate) * 0.010f); + const int thirtyMillisecondSample = + juce::roundToInt( + static_cast<float>(fixtureSampleRate) * 0.030f); + for (int sample = 0; + sample < observationSamples; + ++sample) + { + const float reference = + fadeReferenceCapture.getSample(0, sample); + if (std::abs(reference) <= 0.10f) + continue; + + const float ratio = + fadeCapture.getSample(0, sample) / reference; + if (firstMeasuredSample < 0) + { + firstMeasuredSample = sample; + firstRatio = ratio; + } + if (havePreviousRatio) + { + maximumRatioStep = juce::jmax( + maximumRatioStep, + std::abs(ratio - previousRatio)); + } + previousRatio = ratio; + havePreviousRatio = true; + if (sample == tenMillisecondSample) + tenMillisecondRatio = ratio; + if (sample == thirtyMillisecondSample) + { + thirtyMillisecondError = + std::abs(ratio - 1.0f); + } + if (firstUnitySample < 0 + && ratio >= 0.9999f) + { + firstUnitySample = sample; + } + } + + S13NAMRack bypassMarkerRack; + S13NAMRack bypassSilenceRack; + prepareRack(bypassMarkerRack, true); + prepareRack(bypassSilenceRack, true); + warmSilence(bypassMarkerRack, midi); + warmSilence(bypassSilenceRack, midi); + bypassMarkerRack.delayEnabled.store(0.0f); + bypassSilenceRack.delayEnabled.store(0.0f); + + juce::AudioBuffer<float> markerCapture( + 2, observationSamples); + juce::AudioBuffer<float> silenceCapture( + 2, observationSamples); + markerCapture.clear(); + silenceCapture.clear(); + for (int blockIndex = 0; + blockIndex < observationBlocks; + ++blockIndex) + { + juce::AudioBuffer<float> markerBlock( + 2, fixtureBlockSize); + juce::AudioBuffer<float> silenceBlock( + 2, fixtureBlockSize); + markerBlock.clear(); + silenceBlock.clear(); + if (blockIndex == 0) + { + markerBlock.setSample(0, 0, 0.64f); + markerBlock.setSample(1, 0, -0.41f); + } + bypassMarkerRack.processBlock(markerBlock, midi); + bypassSilenceRack.processBlock( + silenceBlock, midi); + markerCapture.copyFrom( + 0, + blockIndex * fixtureBlockSize, + markerBlock, + 0, + 0, + fixtureBlockSize); + markerCapture.copyFrom( + 1, + blockIndex * fixtureBlockSize, + markerBlock, + 1, + 0, + fixtureBlockSize); + silenceCapture.copyFrom( + 0, + blockIndex * fixtureBlockSize, + silenceBlock, + 0, + 0, + fixtureBlockSize); + silenceCapture.copyFrom( + 1, + blockIndex * fixtureBlockSize, + silenceBlock, + 1, + 0, + fixtureBlockSize); + } + + int directImpulseSample = -1; + float directImpulsePeak = 0.0f; + for (int sample = 0; + sample < observationSamples; + ++sample) + { + const float difference = std::abs( + markerCapture.getSample(0, sample) + - silenceCapture.getSample(0, sample)); + if (difference > directImpulsePeak) + { + directImpulsePeak = difference; + directImpulseSample = sample; + } + } + + float rejectedInputEchoPeak = 0.0f; + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; + sample < observationSamples; + ++sample) + { + if (std::abs(sample - directImpulseSample) <= 2) + continue; + rejectedInputEchoPeak = juce::jmax( + rejectedInputEchoPeak, + std::abs( + markerCapture.getSample( + channel, sample) + - silenceCapture.getSample( + channel, sample))); + } + } + + const int minimumFadeSamples = juce::roundToInt( + static_cast<float>(fixtureSampleRate) * 0.019f); + const bool pass = + firstMeasuredSample >= 0 + && firstRatio > 0.15f + && firstRatio < 0.35f + && tenMillisecondRatio > 0.48f + && tenMillisecondRatio < 0.72f + && firstUnitySample >= minimumFadeSamples + && maximumRatioStep < 0.005f + && thirtyMillisecondError <= 1.0e-6f + && directImpulsePeak > 0.05f + && rejectedInputEchoPeak <= 1.0e-7f; + + auto* value = new juce::DynamicObject(); + value->setProperty( + "minimumDelayMs", minimumDelayMs); + value->setProperty( + "feedback", 0.0); + value->setProperty( + "reportedRackLatencySamples", + fadeRack.getLatencySamples()); + value->setProperty( + "firstMeasuredSample", firstMeasuredSample); + value->setProperty( + "firstDryRatio", firstRatio); + value->setProperty( + "tenMillisecondDryRatio", + tenMillisecondRatio); + value->setProperty( + "firstUnityDrySample", firstUnitySample); + value->setProperty( + "minimumAcceptedFadeSamples", + minimumFadeSamples); + value->setProperty( + "maximumAdjacentDryRatioStep", + maximumRatioStep); + value->setProperty( + "thirtyMillisecondDryError", + thirtyMillisecondError); + value->setProperty( + "bypassDirectImpulseSample", + directImpulseSample); + value->setProperty( + "bypassDirectImpulsePeak", + directImpulsePeak); + value->setProperty( + "rejectedBypassInputEchoPeak", + rejectedInputEchoPeak); + value->setProperty("pass", pass); + return juce::var(value); +} + +juce::var NAMDelayRegression::runTrackProcessorSparseTailServiceProbe() +{ + struct SparseDelayPlayHead final : juce::AudioPlayHead + { + juce::Optional<PositionInfo> getPosition() const override + { + PositionInfo info; + info.setBpm(10.0); + info.setIsPlaying(true); + return info; + } + } playHead; + + auto rack = std::make_unique<S13NAMRack>(); + auto* const rackPointer = rack.get(); + configureNeutralRack(*rackPointer); + rackPointer->delayEnabled.store(1.0f); + rackPointer->delayTimeMs.store(250.0f); + rackPointer->delayFeedback.store(0.85f); + rackPointer->delayMix.store(1.0f); + rackPointer->delayMod.store(0.0f); + rackPointer->delayDucker.store(0.0f); + rackPointer->delayMode.store(0.0f); + rackPointer->delayPingPong.store(0.0f); + rackPointer->delayTempoSync.store(1.0f); + rackPointer->setPlayHead(&playHead); + + TrackProcessor track; + track.stopTimer(); + track.setTrackType(TrackType::Audio); + const bool addPassed = track.addTrackFX( + std::move(rack), fixtureSampleRate, fixtureBlockSize); + track.prepareToPlay(fixtureSampleRate, fixtureBlockSize); + const int initialBudgetSamples = + track.realtimeFXTailBudgetSamples.load( + std::memory_order_acquire); + const int initialMinimumSamples = + track.realtimeFXTailMinimumDrainSamples.load( + std::memory_order_acquire); + const bool representsMoreThan120Seconds = + initialBudgetSamples + > juce::roundToInt(120.0 * fixtureSampleRate) + && initialMinimumSamples + > juce::roundToInt(120.0 * fixtureSampleRate); + + constexpr double renderSeconds = 12.20; + const int renderSamples = juce::roundToInt( + renderSeconds * fixtureSampleRate); + const int timerPeriodSamples = juce::jmax( + fixtureBlockSize, + juce::roundToInt(0.250 * fixtureSampleRate)); + int samplesUntilTimer = timerPeriodSamples; + int timerMaintenanceCalls = 0; + float firstRepeatPeak = 0.0f; + float secondRepeatPeak = 0.0f; + juce::MidiBuffer midi; + for (int absoluteStart = 0; + absoluteStart < renderSamples; + absoluteStart += fixtureBlockSize) + { + juce::AudioBuffer<float> block( + 2, fixtureBlockSize); + block.clear(); + if (absoluteStart == 0) + { + block.setSample(0, 0, 0.55f); + block.setSample(1, 0, -0.31f); + } + track.processBlock(block, midi); + for (int sample = 0; + sample < fixtureBlockSize; + ++sample) + { + const double timeSeconds = + static_cast<double>(absoluteStart + sample) + / fixtureSampleRate; + const float peak = juce::jmax( + std::abs(block.getSample(0, sample)), + std::abs(block.getSample(1, sample))); + if (timeSeconds >= 5.95 && timeSeconds <= 6.05) + firstRepeatPeak = juce::jmax(firstRepeatPeak, peak); + if (timeSeconds >= 11.95 && timeSeconds <= 12.05) + secondRepeatPeak = juce::jmax(secondRepeatPeak, peak); + } + + samplesUntilTimer -= fixtureBlockSize; + while (samplesUntilTimer <= 0) + { + track.timerCallback(); + ++timerMaintenanceCalls; + samplesUntilTimer += timerPeriodSamples; + } + } + + const bool sparseRepeatsSurvived = + firstRepeatPeak > 1.0e-5f + && secondRepeatPeak > 1.0e-5f + && track.realtimeFXTailActive.load( + std::memory_order_acquire) + && ! track.realtimeFXTailResetPending.load( + std::memory_order_acquire) + && track.realtimeFXTailMinimumSamplesRemaining > 0; + + const int previousPublishedBudget = + track.realtimeFXTailLastPublishedBudgetSamples; + const int increasedPublishedBudget = juce::jmin( + std::numeric_limits<int>::max() - fixtureBlockSize * 4, + juce::jmax( + previousPublishedBudget, + track.realtimeFXTailHardSamplesRemaining) + + juce::roundToInt(2.0 * fixtureSampleRate)); + track.realtimeFXTailBudgetSamples.store( + increasedPublishedBudget, std::memory_order_release); + track.realtimeFXTailMinimumDrainSamples.store( + increasedPublishedBudget - fixtureBlockSize, + std::memory_order_release); + juce::AudioBuffer<float> adoptionBlock( + 2, fixtureBlockSize); + adoptionBlock.clear(); + track.processBlock(adoptionBlock, midi); + const int afterIncreaseSamples = + track.realtimeFXTailHardSamplesRemaining; + adoptionBlock.clear(); + track.processBlock(adoptionBlock, midi); + const int afterUnchangedSamples = + track.realtimeFXTailHardSamplesRemaining; + const bool upwardBudgetAdoptedOnce = + afterIncreaseSamples + == increasedPublishedBudget - fixtureBlockSize + && afterUnchangedSamples + == afterIncreaseSamples - fixtureBlockSize; + + auto* value = new juce::DynamicObject(); + value->setProperty("trackFXAdded", addPassed); + value->setProperty( + "initialBudgetSamples", initialBudgetSamples); + value->setProperty( + "initialMinimumDrainSamples", initialMinimumSamples); + value->setProperty( + "representsMoreThan120Seconds", + representsMoreThan120Seconds); + value->setProperty( + "timerMaintenanceCalls", timerMaintenanceCalls); + value->setProperty("firstRepeatPeak", firstRepeatPeak); + value->setProperty("secondRepeatPeak", secondRepeatPeak); + value->setProperty( + "sparseRepeatsSurvived", sparseRepeatsSurvived); + value->setProperty( + "increasedPublishedBudget", increasedPublishedBudget); + value->setProperty( + "afterIncreaseSamples", afterIncreaseSamples); + value->setProperty( + "afterUnchangedSamples", afterUnchangedSamples); + value->setProperty( + "upwardBudgetAdoptedOnce", upwardBudgetAdoptedOnce); + value->setProperty( + "pass", + addPassed + && representsMoreThan120Seconds + && timerMaintenanceCalls >= 40 + && sparseRepeatsSurvived + && upwardBudgetAdoptedOnce); + return juce::var(value); +} + +juce::Array<juce::var> NAMDelayRegression::runRackTailChecks() +{ + juce::Array<juce::var> checks; + + const auto rackDelaySpilloverProbe = + runRackDelaySpilloverProbe(); + addCheck( + checks, + "rack_delay_bypass_spills_then_becomes_dry", + rackDelaySpilloverProbe.getProperty("pass", false) + ? "pass" + : "fail", + "Disabling the rack delay must preserve its already-recorded echo over unity dry, reject new bypass input from the delay line, and return to the fixed-latency dry reference after the bounded tail.", + rackDelaySpilloverProbe); + const auto rackDelayV10FrozenTailAndBudgetProbe = + runRackDelayV10FrozenTailAndBudgetProbe(); + addCheck( + checks, + "rack_delay_v10_frozen_tail_macro_and_downward_budget", + rackDelayV10FrozenTailAndBudgetProbe.getProperty( + "pass", false) + ? "pass" + : "fail", + "After Delay bypass, edits to every visible macro source must not alter the already-generated Dual tail. While active, downward Feedback, Time, or Mix automation may update the frozen macro for the eventual bypass, but both the processor's published live-tail bound and the rack's armed budget must retain the elapsed conservative bound. The budget must also include the additive input-send release horizon and both current plus queued time-morph horizons, so late release-fed or rapidly retargeted echoes cannot be truncated.", + rackDelayV10FrozenTailAndBudgetProbe); + const auto rackMinimumDelayBypassProbe = + runRackMinimumDelayBypassProbe(); + addCheck( + checks, + "rack_delay_minimum_time_bypass_is_smooth_and_input_isolated", + rackMinimumDelayBypassProbe.getProperty("pass", false) + ? "pass" + : "fail", + "At the 1 ms minimum time and zero feedback, rack Delay bypass must reject new input from the delay line, ramp its dry gain without a block-edge jump, retain the bounded drain for at least the 20 ms transition, and then become exactly dry.", + rackMinimumDelayBypassProbe); + const auto trackProcessorSparseTailServiceProbe = + runTrackProcessorSparseTailServiceProbe(); + addCheck( + checks, + "track_processor_sparse_delay_tail_service_contract", + trackProcessorSparseTailServiceProbe.getProperty("pass", false) + ? "pass" + : "fail", + "TrackProcessor must represent the Rack's greater-than-120-second 10-BPM delay tail, run actual control-thread timer maintenance without treating the silence between 6-second repeats as completion, preserve the second repeat at 12 seconds, and adopt a newly published longer live budget exactly once.", + trackProcessorSparseTailServiceProbe); + + return checks; +} diff --git a/Source/NAMModelSafety.h b/Source/NAMModelSafety.h new file mode 100644 index 0000000..5f706b5 --- /dev/null +++ b/Source/NAMModelSafety.h @@ -0,0 +1,13 @@ +#pragma once + +#include <JuceHeader.h> + +namespace OpenStudioNAMModelSafety +{ +// Real-world NAM captures are generally far smaller than this. Keeping the +// supported ceiling at 64 MiB bounds the simultaneous UTF-8, parsed JSON, and +// dual-lane DSP construction footprint in the live process. +inline constexpr juce::int64 maximumFileBytes = + static_cast<juce::int64>(64) * 1024 * 1024; +inline constexpr const char* maximumFileDescription = "64 MiB"; +} diff --git a/Source/NAMPolyOctaver.cpp b/Source/NAMPolyOctaver.cpp new file mode 100644 index 0000000..fbe0460 --- /dev/null +++ b/Source/NAMPolyOctaver.cpp @@ -0,0 +1,1584 @@ +/* + NAMPolyOctaver: stereo multirate ERB phase-scaling octave generator. + + The constant-ERB complex-filter layout, octave phase-scaling equations, + 6:1 multirate topology, FIR coefficients, and fast square-root method are + adapted from: + + terrarium-poly-octave + Copyright (c) 2024 Steven Schulteis + https://github.com/schult/terrarium-poly-octave + + That implementation follows the ERB-PS2 method described by Etienne + Thuillier in "Real-Time Polyphonic Octave Doubling for the Guitar" + (Aalto University, 2016). OpenStudio's implementation adds stereo state, + sample-rate-aware ERB coefficients, arbitrary host-block partitioning, + finite recovery, atomic parameters, and deterministic test access. It + does not depend on the Daisy, Q, or GCEM libraries used by the firmware. + + MIT License + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. +*/ + +#include "NAMPolyOctaver.h" + +#include <algorithm> +#include <cmath> +#include <cstring> +#include <limits> +#include <vector> + +namespace +{ +constexpr float nominalDecimatorPassbandHz = 1800.0f; +constexpr float nominalInterpolatorStopbandHz = 4400.0f; + +float sanitiseLevel(float value, float fallback) noexcept +{ + if (! std::isfinite(value)) + return fallback; + return juce::jlimit(0.0f, 1.25f, value); +} + +float maximumAbsoluteDifference(const std::vector<float>& left, + const std::vector<float>& right) noexcept +{ + const std::size_t count = juce::jmin(left.size(), right.size()); + float maximum = left.size() == right.size() + ? 0.0f + : std::numeric_limits<float>::infinity(); + for (std::size_t index = 0; index < count; ++index) + maximum = juce::jmax(maximum, std::abs(left[index] - right[index])); + return maximum; +} + +float maximumAbsoluteValue(const std::vector<float>& values) noexcept +{ + float maximum = 0.0f; + for (const float value : values) + maximum = juce::jmax(maximum, std::abs(value)); + return maximum; +} + +double vectorRms(const std::vector<float>& values, + std::size_t begin) noexcept +{ + if (begin >= values.size()) + return 0.0; + double sumSquares = 0.0; + for (std::size_t index = begin; index < values.size(); ++index) + { + const double value = static_cast<double>(values[index]); + sumSquares += value * value; + } + return std::sqrt(sumSquares + / static_cast<double>(values.size() - begin)); +} + +double toneMagnitude(const std::vector<float>& values, + std::size_t begin, + double sampleRate, + double frequency) noexcept +{ + if (begin >= values.size() + || frequency <= 0.0 + || frequency >= sampleRate * 0.5) + { + return 0.0; + } + + double real = 0.0; + double imaginary = 0.0; + const double angularFrequency = 2.0 * juce::MathConstants<double>::pi + * frequency / sampleRate; + for (std::size_t index = begin; index < values.size(); ++index) + { + const double phase = angularFrequency + * static_cast<double>(index - begin); + const double value = static_cast<double>(values[index]); + real += value * std::cos(phase); + imaginary -= value * std::sin(phase); + } + return 2.0 * std::sqrt(real * real + imaginary * imaginary) + / static_cast<double>(values.size() - begin); +} + +float ratioToDecibels(double numerator, double denominator) noexcept +{ + constexpr double floor = 1.0e-15; + return static_cast<float>(20.0 * std::log10( + juce::jmax(floor, numerator) / juce::jmax(floor, denominator))); +} +} + +NAMPolyOctaver::NAMPolyOctaver() noexcept +{ + smoothedDirectLevel.setCurrentAndTargetValue(1.0f); + smoothedOctaveDownLevel.setCurrentAndTargetValue(0.0f); + smoothedOctaveUpLevel.setCurrentAndTargetValue(0.0f); + for (auto& profile : smoothedBassProfile) + profile.setCurrentAndTargetValue(0.0f); +} + +float NAMPolyOctaver::DecimatorState::stageOne() const noexcept +{ + return + 0.000066177472224418f * (fullRate.atAge(11) + fullRate.atAge(31)) + + 0.0009613901552378511f * (fullRate.atAge(12) + fullRate.atAge(30)) + + 0.003835090815380887f * (fullRate.atAge(13) + fullRate.atAge(29)) + + 0.010496532623165526f * (fullRate.atAge(14) + fullRate.atAge(28)) + + 0.02272703591356282f * (fullRate.atAge(15) + fullRate.atAge(27)) + + 0.041464390530886956f * (fullRate.atAge(16) + fullRate.atAge(26)) + + 0.06591039391505207f * (fullRate.atAge(17) + fullRate.atAge(25)) + + 0.09309984953947406f * (fullRate.atAge(18) + fullRate.atAge(24)) + + 0.11829177835273737f * (fullRate.atAge(19) + fullRate.atAge(23)) + + 0.13620590247679107f * (fullRate.atAge(20) + fullRate.atAge(22)) + + 0.14270010010002276f * fullRate.atAge(21); +} + +float NAMPolyOctaver::DecimatorState::stageTwo() const noexcept +{ + return + -0.00299995f * (oneThirdRate.atAge(1) + oneThirdRate.atAge(15)) + + 0.01858487f * (oneThirdRate.atAge(3) + oneThirdRate.atAge(13)) + - 0.06984829f * (oneThirdRate.atAge(5) + oneThirdRate.atAge(11)) + + 0.30421664f * (oneThirdRate.atAge(7) + oneThirdRate.atAge(9)) + + 0.5f * oneThirdRate.atAge(8); +} + +float NAMPolyOctaver::DecimatorState::process( + const std::array<float, resampleFactor>& input) noexcept +{ + fullRate.push(input[0]); + fullRate.push(input[1]); + fullRate.push(input[2]); + oneThirdRate.push(stageOne()); + + fullRate.push(input[3]); + fullRate.push(input[4]); + fullRate.push(input[5]); + oneThirdRate.push(stageOne()); + + return stageTwo(); +} + +void NAMPolyOctaver::DecimatorState::reset() noexcept +{ + fullRate.clear(); + oneThirdRate.clear(); +} + +float NAMPolyOctaver::InterpolatorState::stageOneEven() const noexcept +{ + return + -0.0028536199247471473f + * (reducedRate.atAge(7) + reducedRate.atAge(31)) + - 0.040326725115203695f + * (reducedRate.atAge(8) + reducedRate.atAge(30)) + - 0.036134596458820015f + * (reducedRate.atAge(9) + reducedRate.atAge(29)) + + 0.033522051189265496f + * (reducedRate.atAge(10) + reducedRate.atAge(28)) + - 0.031442224275585025f + * (reducedRate.atAge(11) + reducedRate.atAge(27)) + + 0.03258337681750486f + * (reducedRate.atAge(12) + reducedRate.atAge(26)) + - 0.03538414864961937f + * (reducedRate.atAge(13) + reducedRate.atAge(25)) + + 0.038811868988079715f + * (reducedRate.atAge(14) + reducedRate.atAge(24)) + - 0.042204493894155204f + * (reducedRate.atAge(15) + reducedRate.atAge(23)) + + 0.045128824129776035f + * (reducedRate.atAge(16) + reducedRate.atAge(22)) + - 0.04736995557907843f + * (reducedRate.atAge(17) + reducedRate.atAge(21)) + + 0.048831901671617876f + * (reducedRate.atAge(18) + reducedRate.atAge(20)) + + 0.9507771467941135f * reducedRate.atAge(19); +} + +float NAMPolyOctaver::InterpolatorState::stageOneOdd() const noexcept +{ + return + -0.015961858776449508f + * (reducedRate.atAge(7) + reducedRate.atAge(30)) + - 0.056128740058266235f + * (reducedRate.atAge(8) + reducedRate.atAge(29)) + + 0.011026026040094625f + * (reducedRate.atAge(9) + reducedRate.atAge(28)) + + 0.003198795994721635f + * (reducedRate.atAge(10) + reducedRate.atAge(27)) + - 0.01108582057161854f + * (reducedRate.atAge(11) + reducedRate.atAge(26)) + + 0.01951384497860086f + * (reducedRate.atAge(12) + reducedRate.atAge(25)) + - 0.030860282826182514f + * (reducedRate.atAge(13) + reducedRate.atAge(24)) + + 0.04707993944078406f + * (reducedRate.atAge(14) + reducedRate.atAge(23)) + - 0.07155908583004919f + * (reducedRate.atAge(15) + reducedRate.atAge(22)) + + 0.1129220770668398f + * (reducedRate.atAge(16) + reducedRate.atAge(21)) + - 0.2033122562119347f + * (reducedRate.atAge(17) + reducedRate.atAge(20)) + + 0.6336728217960803f + * (reducedRate.atAge(18) + reducedRate.atAge(19)); +} + +float NAMPolyOctaver::InterpolatorState::stageTwoPhaseZero() const noexcept +{ + return + 0.00036440608905813593f * oneThirdRate.atAge(5) + + 0.0005821260464558225f * oneThirdRate.atAge(6) + - 0.043244023722481956f * oneThirdRate.atAge(7) + - 0.10310036386076359f * oneThirdRate.atAge(8) + + 0.13604229993913602f * oneThirdRate.atAge(9) + + 0.5503466630244301f * oneThirdRate.atAge(10) + + 0.4407091552750118f * oneThirdRate.atAge(11) + + 0.009420000864297772f * oneThirdRate.atAge(12) + - 0.09801301258361905f * oneThirdRate.atAge(13) + - 0.019627176246818184f * oneThirdRate.atAge(14) + + 0.001762424830497545f * oneThirdRate.atAge(15); +} + +float NAMPolyOctaver::InterpolatorState::stageTwoPhaseOne() const noexcept +{ + return + 0.001112114188613258f + * (oneThirdRate.atAge(5) + oneThirdRate.atAge(15)) + - 0.005449383064836152f + * (oneThirdRate.atAge(6) + oneThirdRate.atAge(14)) + - 0.07276547446584428f + * (oneThirdRate.atAge(7) + oneThirdRate.atAge(13)) + - 0.0709695783332148f + * (oneThirdRate.atAge(8) + oneThirdRate.atAge(12)) + + 0.2904591843823435f + * (oneThirdRate.atAge(9) + oneThirdRate.atAge(11)) + + 0.590541634315722f * oneThirdRate.atAge(10); +} + +float NAMPolyOctaver::InterpolatorState::stageTwoPhaseTwo() const noexcept +{ + return + 0.001762424830497545f * oneThirdRate.atAge(5) + - 0.019627176246818184f * oneThirdRate.atAge(6) + - 0.09801301258361905f * oneThirdRate.atAge(7) + + 0.009420000864297772f * oneThirdRate.atAge(8) + + 0.4407091552750118f * oneThirdRate.atAge(9) + + 0.5503466630244301f * oneThirdRate.atAge(10) + + 0.13604229993913602f * oneThirdRate.atAge(11) + - 0.10310036386076359f * oneThirdRate.atAge(12) + - 0.043244023722481956f * oneThirdRate.atAge(13) + + 0.0005821260464558225f * oneThirdRate.atAge(14) + + 0.00036440608905813593f * oneThirdRate.atAge(15); +} + +void NAMPolyOctaver::InterpolatorState::process( + float input, + std::array<float, resampleFactor>& output) noexcept +{ + reducedRate.push(input); + + oneThirdRate.push(stageOneEven()); + output[0] = stageTwoPhaseZero(); + output[1] = stageTwoPhaseOne(); + output[2] = stageTwoPhaseTwo(); + + oneThirdRate.push(stageOneOdd()); + output[3] = stageTwoPhaseZero(); + output[4] = stageTwoPhaseOne(); + output[5] = stageTwoPhaseTwo(); +} + +void NAMPolyOctaver::InterpolatorState::reset() noexcept +{ + reducedRate.clear(); + oneThirdRate.clear(); +} + +void NAMPolyOctaver::ChannelRateState::reset() noexcept +{ + decimator.reset(); + downInterpolator.reset(); + upInterpolator.reset(); + pendingInput.fill(0.0f); + pendingDownOutput.fill(0.0f); + pendingUpOutput.fill(0.0f); + phase = 0; + downInterpolatorActive = false; + upInterpolatorActive = false; + octaveDownDcInput = 0.0f; + octaveDownDcOutput = 0.0f; +} + +float NAMPolyOctaver::bandCentreHz(int erbIndex) noexcept +{ + return 480.0f * std::pow(2.0f, 0.027f * static_cast<float>(erbIndex)) + - 420.0f; +} + +float NAMPolyOctaver::bandBandwidthHz(int erbIndex) noexcept +{ + const float previous = bandCentreHz(erbIndex - 1); + const float current = bandCentreHz(erbIndex); + const float next = bandCentreHz(erbIndex + 1); + const float lowerSpacing = juce::jmax(0.001f, current - previous); + const float upperSpacing = juce::jmax(0.001f, next - current); + return 2.0f * lowerSpacing * upperSpacing + / (lowerSpacing + upperSpacing); +} + +float NAMPolyOctaver::fastInverseSqrt(float value) noexcept +{ + static_assert(std::numeric_limits<float>::is_iec559, + "Fast inverse square root requires IEEE-754 float"); + if (! std::isfinite(value) || value <= phaseEnergyFloor) + return 0.0f; + + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + bits = 0x5F1FFFF9U - (bits >> 1U); + float estimate = 0.0f; + std::memcpy(&estimate, &bits, sizeof(estimate)); + return estimate * (0.703952253f + * (2.38924456f - value * estimate * estimate)); +} + +float NAMPolyOctaver::fastSqrt(float value) noexcept +{ + return value > phaseEnergyFloor + ? value * fastInverseSqrt(value) + : 0.0f; +} + +NAMPolyOctaver::ComplexValue NAMPolyOctaver::multiply( + ComplexValue left, + ComplexValue right) noexcept +{ + return { + left.real * right.real - left.imag * right.imag, + left.real * right.imag + left.imag * right.real + }; +} + +void NAMPolyOctaver::prepare(double sampleRate, int maximumBlockSize) noexcept +{ + prepared.store(false, std::memory_order_release); + + const double safeSampleRate = sampleRate > 1000.0 + ? sampleRate + : 44100.0; + diagnosticSampleRate.store( + static_cast<float>(safeSampleRate), std::memory_order_relaxed); + diagnosticMaximumBlockSize.store( + juce::jmax(1, maximumBlockSize), std::memory_order_relaxed); + + designFilterBank(safeSampleRate); + octaveDownHighPassCoefficient = std::exp( + -juce::MathConstants<float>::twoPi * 18.0f + / static_cast<float>(safeSampleRate)); + + smoothedDirectLevel.reset(safeSampleRate, levelRampSeconds); + smoothedOctaveDownLevel.reset(safeSampleRate, levelRampSeconds); + smoothedOctaveUpLevel.reset(safeSampleRate, levelRampSeconds); + for (auto& profile : smoothedBassProfile) + { + profile.reset( + safeSampleRate / static_cast<double>(resampleFactor), + levelRampSeconds); + profile.setCurrentAndTargetValue( + requestedInstrumentProfile.load( + std::memory_order_relaxed) == 1 + ? 1.0f + : 0.0f); + } + smoothedDirectLevel.setCurrentAndTargetValue( + requestedDirectLevel.load(std::memory_order_relaxed)); + smoothedOctaveDownLevel.setCurrentAndTargetValue( + requestedOctaveDownLevel.load(std::memory_order_relaxed)); + smoothedOctaveUpLevel.setCurrentAndTargetValue( + requestedOctaveUpLevel.load(std::memory_order_relaxed)); + + resetDspState(); + resetDiagnostics(); + prepared.store(true, std::memory_order_release); +} + +void NAMPolyOctaver::designFilterBank(double sampleRate) noexcept +{ + bands.fill(BandCoefficients {}); + activeBandCount = 0; + octaveUpBandCount = 0; + + const double reducedSampleRate = juce::jmax( + 1000.0, sampleRate / static_cast<double>(resampleFactor)); + const double pi = juce::MathConstants<double>::pi; + const double squareRootTwo = std::sqrt(2.0); + + for (int band = 0; band < maximumBands; ++band) + { + // Array indices 0..3 are the Bass-only B0/E1 extension. Indices + // 4..83 retain the original Guitar ERB indices 0..79 exactly. + const int erbIndex = band - bassExtendedBandCount; + const float centre = bandCentreHz(erbIndex); + if (! std::isfinite(centre) + || centre <= 0.0f + || centre >= static_cast<float>(reducedSampleRate * 0.46)) + { + break; + } + + const float bandwidth = juce::jmax( + 1.0f, bandBandwidthHz(erbIndex)); + const double angularBandwidth = + pi * static_cast<double>(bandwidth) / reducedSampleRate; + const double cosineBandwidth = std::cos(angularBandwidth); + const double sineBandwidth = std::sin(angularBandwidth); + const double denominator = + 1.0 + squareRootTwo * sineBandwidth * 0.5; + const double prototypeGain = + (1.0 - cosineBandwidth) / (2.0 * denominator); + const double centreRadians = + 2.0 * pi * static_cast<double>(centre) / reducedSampleRate; + const double centreCosine = std::cos(centreRadians); + const double centreSine = std::sin(centreRadians); + const double doubleCentreCosine = std::cos(2.0 * centreRadians); + const double doubleCentreSine = std::sin(2.0 * centreRadians); + const double c1Scale = -2.0 * cosineBandwidth / denominator; + const double c2Scale = + (1.0 - squareRootTwo * sineBandwidth * 0.5) / denominator; + + auto& coefficients = bands[static_cast<std::size_t>(band)]; + coefficients.centreHz = centre; + coefficients.bandwidthHz = bandwidth; + coefficients.d0 = static_cast<float>(prototypeGain); + coefficients.d1 = { + static_cast<float>(2.0 * prototypeGain * centreCosine), + static_cast<float>(2.0 * prototypeGain * centreSine) + }; + coefficients.d2 = { + static_cast<float>(prototypeGain * doubleCentreCosine), + static_cast<float>(prototypeGain * doubleCentreSine) + }; + coefficients.c1 = { + static_cast<float>(c1Scale * centreCosine), + static_cast<float>(c1Scale * centreSine) + }; + coefficients.c2 = { + static_cast<float>(c2Scale * doubleCentreCosine), + static_cast<float>(c2Scale * doubleCentreSine) + }; + + ++activeBandCount; + ++octaveUpBandCount; + } + + const float hostRateScale = static_cast<float>(sampleRate / 48000.0); + const bool bassProfile = requestedInstrumentProfile.load( + std::memory_order_relaxed) == 1; + diagnosticActiveBandCount.store( + juce::jmin(80, activeBandCount), + std::memory_order_relaxed); + diagnosticOctaveUpBandCount.store( + juce::jmin(80, octaveUpBandCount), + std::memory_order_relaxed); + diagnosticLowestBandCentreHz.store( + activeBandCount > 0 + ? bands[static_cast<std::size_t>( + bassProfile ? 0 : bassExtendedBandCount)].centreHz + : 0.0f, + std::memory_order_relaxed); + diagnosticHighestBandCentreHz.store( + activeBandCount > 0 + ? bands[static_cast<std::size_t>(juce::jmin( + activeBandCount - 1, + bassProfile ? 79 : 83))].centreHz + : 0.0f, + std::memory_order_relaxed); + diagnosticWetAntiAliasCutoffHz.store( + nominalDecimatorPassbandHz * hostRateScale, + std::memory_order_relaxed); +} + +void NAMPolyOctaver::resetDspState() noexcept +{ + for (auto& channel : channelStates) + for (auto& state : channel) + state = BandChannelState {}; + for (auto& state : channelRateStates) + state.reset(); + dspStateIsReset = true; +} + +void NAMPolyOctaver::reset() noexcept +{ + resetDspState(); + + const double sampleRate = juce::jmax( + 1.0, + static_cast<double>( + diagnosticSampleRate.load(std::memory_order_relaxed))); + smoothedDirectLevel.reset(sampleRate, levelRampSeconds); + smoothedOctaveDownLevel.reset(sampleRate, levelRampSeconds); + smoothedOctaveUpLevel.reset(sampleRate, levelRampSeconds); + for (auto& profile : smoothedBassProfile) + { + profile.reset( + sampleRate / static_cast<double>(resampleFactor), + levelRampSeconds); + profile.setCurrentAndTargetValue( + requestedInstrumentProfile.load( + std::memory_order_relaxed) == 1 + ? 1.0f + : 0.0f); + } + smoothedDirectLevel.setCurrentAndTargetValue( + requestedDirectLevel.load(std::memory_order_relaxed)); + smoothedOctaveDownLevel.setCurrentAndTargetValue( + requestedOctaveDownLevel.load(std::memory_order_relaxed)); + smoothedOctaveUpLevel.setCurrentAndTargetValue( + requestedOctaveUpLevel.load(std::memory_order_relaxed)); + resetDiagnostics(); +} + +void NAMPolyOctaver::setLevels(float directLevel, + float octaveDownLevel, + float octaveUpLevel) noexcept +{ + requestedDirectLevel.store( + sanitiseLevel(directLevel, 1.0f), std::memory_order_relaxed); + requestedOctaveDownLevel.store( + sanitiseLevel(octaveDownLevel, 0.0f), std::memory_order_relaxed); + requestedOctaveUpLevel.store( + sanitiseLevel(octaveUpLevel, 0.0f), std::memory_order_relaxed); +} + +void NAMPolyOctaver::setInstrumentProfile(int profile) noexcept +{ + const int safeProfile = profile == 1 ? 1 : 0; + requestedInstrumentProfile.store( + safeProfile, + std::memory_order_relaxed); + if (prepared.load(std::memory_order_acquire) + && activeBandCount > bassExtendedBandCount) + { + diagnosticLowestBandCentreHz.store( + bands[static_cast<std::size_t>( + safeProfile == 1 ? 0 : bassExtendedBandCount)].centreHz, + std::memory_order_relaxed); + diagnosticHighestBandCentreHz.store( + bands[static_cast<std::size_t>(juce::jmin( + activeBandCount - 1, + safeProfile == 1 ? 79 : 83))].centreHz, + std::memory_order_relaxed); + } +} + +void NAMPolyOctaver::synchroniseLevelTargets() noexcept +{ + const float direct = requestedDirectLevel.load(std::memory_order_relaxed); + const float down = requestedOctaveDownLevel.load(std::memory_order_relaxed); + const float up = requestedOctaveUpLevel.load(std::memory_order_relaxed); + if (smoothedDirectLevel.getTargetValue() != direct) + smoothedDirectLevel.setTargetValue(direct); + if (smoothedOctaveDownLevel.getTargetValue() != down) + smoothedOctaveDownLevel.setTargetValue(down); + if (smoothedOctaveUpLevel.getTargetValue() != up) + smoothedOctaveUpLevel.setTargetValue(up); +} + +void NAMPolyOctaver::synchroniseProfileTargets() noexcept +{ + const float target = requestedInstrumentProfile.load( + std::memory_order_relaxed) == 1 + ? 1.0f + : 0.0f; + for (auto& profile : smoothedBassProfile) + { + if (profile.getTargetValue() != target) + profile.setTargetValue(target); + } +} + +bool NAMPolyOctaver::wetPathIsExactlySilent() const noexcept +{ + return ! smoothedOctaveDownLevel.isSmoothing() + && ! smoothedOctaveUpLevel.isSmoothing() + && smoothedOctaveDownLevel.getCurrentValue() == 0.0f + && smoothedOctaveDownLevel.getTargetValue() == 0.0f + && smoothedOctaveUpLevel.getCurrentValue() == 0.0f + && smoothedOctaveUpLevel.getTargetValue() == 0.0f; +} + +bool NAMPolyOctaver::directPathIsExactlyUnity() const noexcept +{ + return ! smoothedDirectLevel.isSmoothing() + && smoothedDirectLevel.getCurrentValue() == 1.0f + && smoothedDirectLevel.getTargetValue() == 1.0f; +} + +bool NAMPolyOctaver::allPathsAreExactlySilent() const noexcept +{ + return wetPathIsExactlySilent() + && ! smoothedDirectLevel.isSmoothing() + && smoothedDirectLevel.getCurrentValue() == 0.0f + && smoothedDirectLevel.getTargetValue() == 0.0f; +} + +NAMPolyOctaver::VoiceFrame NAMPolyOctaver::processReducedRateSample( + int channel, + float input, + bool generateOctaveDown, + bool generateOctaveUp, + std::uint64_t& nonFiniteCount) noexcept +{ + VoiceFrame voices; + if (channel < 0 || channel >= maximumChannels || activeBandCount <= 0) + return voices; + + if (! std::isfinite(input)) + { + input = 0.0f; + ++nonFiniteCount; + } + + auto& states = channelStates[static_cast<std::size_t>(channel)]; + const float bassBlend = smoothedBassProfile[ + static_cast<std::size_t>(channel)].getNextValue(); + for (int band = 0; band < activeBandCount; ++band) + { + const float profileBandGain = band < bassExtendedBandCount + ? bassBlend + : (band >= 80 ? 1.0f - bassBlend : 1.0f); + const auto& coefficients = bands[static_cast<std::size_t>(band)]; + auto& state = states[static_cast<std::size_t>(band)]; + + const ComplexValue previousBandOutput = state.previousBandOutput; + const ComplexValue bandOutput { + state.state2.real + coefficients.d0 * input, + state.state2.imag + }; + const ComplexValue c1Output = multiply(coefficients.c1, bandOutput); + const ComplexValue c2Output = multiply(coefficients.c2, bandOutput); + const ComplexValue nextState2 { + state.state1.real + coefficients.d1.real * input - c1Output.real, + state.state1.imag + coefficients.d1.imag * input - c1Output.imag + }; + const ComplexValue nextState1 { + coefficients.d2.real * input - c2Output.real, + coefficients.d2.imag * input - c2Output.imag + }; + + if (! std::isfinite(bandOutput.real) + || ! std::isfinite(bandOutput.imag) + || ! std::isfinite(nextState1.real) + || ! std::isfinite(nextState1.imag) + || ! std::isfinite(nextState2.real) + || ! std::isfinite(nextState2.imag)) + { + state = BandChannelState {}; + ++nonFiniteCount; + continue; + } + + state.state1 = nextState1; + state.state2 = nextState2; + state.previousBandOutput = bandOutput; + + if (bandOutput.real < 0.0f + && std::signbit(bandOutput.imag) + != std::signbit(previousBandOutput.imag)) + { + state.octaveDownSign = -state.octaveDownSign; + } + + const float real = bandOutput.real; + const float imag = bandOutput.imag; + const float energy = real * real + imag * imag; + if (! std::isfinite(energy)) + { + state = BandChannelState {}; + ++nonFiniteCount; + continue; + } + if (energy <= phaseEnergyFloor) + continue; + + const float inverseMagnitude = fastInverseSqrt(energy); + + if (generateOctaveUp) + { + voices.octaveUp += profileBandGain + * (real * real - imag * imag) * inverseMagnitude; + } + + if (generateOctaveDown) + { + const float halfNormalisedReal = juce::jlimit( + -0.5f, 0.5f, 0.5f * real * inverseMagnitude); + const float rootReal = fastSqrt( + juce::jmax(0.0f, 0.5f + halfNormalisedReal)); + const float rootImag = (imag < 0.0f ? -1.0f : 1.0f) + * fastSqrt( + juce::jmax(0.0f, 0.5f - halfNormalisedReal)); + voices.octaveDown += profileBandGain * state.octaveDownSign + * (real * rootReal + imag * rootImag); + } + } + + if (! std::isfinite(voices.octaveDown) + || ! std::isfinite(voices.octaveUp)) + { + voices = {}; + for (auto& state : states) + state = BandChannelState {}; + ++nonFiniteCount; + } + return voices; +} + +NAMPolyOctaver::VoiceFrame NAMPolyOctaver::processVoiceSample( + int channel, + float input, + bool generateOctaveDown, + bool generateOctaveUp, + std::uint64_t& nonFiniteCount) noexcept +{ + VoiceFrame output; + if (channel < 0 || channel >= maximumChannels) + return output; + + auto& rateState = channelRateStates[static_cast<std::size_t>(channel)]; + if (! std::isfinite(input)) + { + input = 0.0f; + ++nonFiniteCount; + } + + const auto phaseIndex = static_cast<std::size_t>(rateState.phase); + if (generateOctaveDown) + { + const float rawOctaveDown = + rateState.pendingDownOutput[phaseIndex]; + output.octaveDown = rawOctaveDown + - rateState.octaveDownDcInput + + octaveDownHighPassCoefficient + * rateState.octaveDownDcOutput; + rateState.octaveDownDcInput = rawOctaveDown; + rateState.octaveDownDcOutput = std::isfinite(output.octaveDown) + ? output.octaveDown + : 0.0f; + } + else + { + rateState.octaveDownDcInput = 0.0f; + rateState.octaveDownDcOutput = 0.0f; + } + output.octaveUp = generateOctaveUp + ? rateState.pendingUpOutput[phaseIndex] + : 0.0f; + rateState.pendingInput[phaseIndex] = input; + + ++rateState.phase; + if (rateState.phase >= resampleFactor) + { + const float reducedInput = + rateState.decimator.process(rateState.pendingInput); + if (! std::isfinite(reducedInput)) + { + rateState.reset(); + auto& states = channelStates[static_cast<std::size_t>(channel)]; + for (auto& state : states) + state = BandChannelState {}; + ++nonFiniteCount; + } + else + { + const VoiceFrame reducedVoices = processReducedRateSample( + channel, + reducedInput, + generateOctaveDown, + generateOctaveUp, + nonFiniteCount); + + if (generateOctaveDown) + { + rateState.downInterpolator.process( + reducedVoices.octaveDown, + rateState.pendingDownOutput); + rateState.downInterpolatorActive = true; + } + else + { + if (rateState.downInterpolatorActive) + rateState.downInterpolator.reset(); + rateState.pendingDownOutput.fill(0.0f); + rateState.downInterpolatorActive = false; + } + + if (generateOctaveUp) + { + rateState.upInterpolator.process( + reducedVoices.octaveUp, + rateState.pendingUpOutput); + rateState.upInterpolatorActive = true; + } + else + { + if (rateState.upInterpolatorActive) + rateState.upInterpolator.reset(); + rateState.pendingUpOutput.fill(0.0f); + rateState.upInterpolatorActive = false; + } + rateState.phase = 0; + } + } + + if (! std::isfinite(output.octaveDown) + || ! std::isfinite(output.octaveUp)) + { + output = {}; + rateState.reset(); + auto& states = channelStates[static_cast<std::size_t>(channel)]; + for (auto& state : states) + state = BandChannelState {}; + ++nonFiniteCount; + } + + dspStateIsReset = false; + return output; +} + +void NAMPolyOctaver::processBlock(juce::AudioBuffer<float>& buffer) noexcept +{ + juce::ScopedNoDenormals noDenormals; + + const int numSamples = buffer.getNumSamples(); + const int numChannels = buffer.getNumChannels(); + if (numSamples <= 0 || numChannels <= 0) + return; + + if (! prepared.load(std::memory_order_acquire)) + return; + + synchroniseLevelTargets(); + synchroniseProfileTargets(); + + if (wetPathIsExactlySilent() && directPathIsExactlyUnity()) + { + if (! dspStateIsReset) + resetDspState(); + diagnosticProcessedBlocks.fetch_add(1, std::memory_order_relaxed); + diagnosticProcessedSamples.fetch_add( + static_cast<std::uint64_t>(numSamples), + std::memory_order_relaxed); + diagnosticFastPathBlocks.fetch_add(1, std::memory_order_relaxed); + diagnosticLastBlockDownPeak.store(0.0f, std::memory_order_relaxed); + diagnosticLastBlockUpPeak.store(0.0f, std::memory_order_relaxed); + return; + } + + if (allPathsAreExactlySilent()) + { + if (! dspStateIsReset) + resetDspState(); + buffer.clear(); + diagnosticProcessedBlocks.fetch_add(1, std::memory_order_relaxed); + diagnosticProcessedSamples.fetch_add( + static_cast<std::uint64_t>(numSamples), + std::memory_order_relaxed); + diagnosticFastPathBlocks.fetch_add(1, std::memory_order_relaxed); + diagnosticLastBlockDownPeak.store(0.0f, std::memory_order_relaxed); + diagnosticLastBlockUpPeak.store(0.0f, std::memory_order_relaxed); + return; + } + + if (wetPathIsExactlySilent()) + { + if (! dspStateIsReset) + resetDspState(); + for (int sample = 0; sample < numSamples; ++sample) + { + const float directLevel = smoothedDirectLevel.getNextValue(); + for (int channel = 0; channel < numChannels; ++channel) + buffer.getWritePointer(channel)[sample] *= directLevel; + } + diagnosticProcessedBlocks.fetch_add(1, std::memory_order_relaxed); + diagnosticProcessedSamples.fetch_add( + static_cast<std::uint64_t>(numSamples), + std::memory_order_relaxed); + diagnosticFastPathBlocks.fetch_add(1, std::memory_order_relaxed); + diagnosticLastBlockDownPeak.store(0.0f, std::memory_order_relaxed); + diagnosticLastBlockUpPeak.store(0.0f, std::memory_order_relaxed); + return; + } + + const int processedChannels = juce::jmin(numChannels, maximumChannels); + const bool generateOctaveDown = + smoothedOctaveDownLevel.isSmoothing() + || smoothedOctaveDownLevel.getCurrentValue() != 0.0f + || smoothedOctaveDownLevel.getTargetValue() != 0.0f; + const bool generateOctaveUp = + smoothedOctaveUpLevel.isSmoothing() + || smoothedOctaveUpLevel.getCurrentValue() != 0.0f + || smoothedOctaveUpLevel.getTargetValue() != 0.0f; + std::array<float*, maximumChannels> channelData {}; + for (int channel = 0; channel < processedChannels; ++channel) + channelData[static_cast<std::size_t>(channel)] = + buffer.getWritePointer(channel); + + std::uint64_t nonFiniteCount = 0; + float downPeak = 0.0f; + float upPeak = 0.0f; + for (int sample = 0; sample < numSamples; ++sample) + { + const float directLevel = smoothedDirectLevel.getNextValue(); + const float downLevel = smoothedOctaveDownLevel.getNextValue(); + const float upLevel = smoothedOctaveUpLevel.getNextValue(); + + for (int channel = 0; channel < processedChannels; ++channel) + { + auto* const samples = channelData[static_cast<std::size_t>(channel)]; + float direct = samples[sample]; + if (! std::isfinite(direct)) + { + direct = 0.0f; + ++nonFiniteCount; + } + const VoiceFrame voices = processVoiceSample( + channel, + direct, + generateOctaveDown, + generateOctaveUp, + nonFiniteCount); + downPeak = juce::jmax(downPeak, std::abs(voices.octaveDown)); + upPeak = juce::jmax(upPeak, std::abs(voices.octaveUp)); + const float mixed = direct * directLevel + + voices.octaveDown * downLevel + + voices.octaveUp * upLevel; + if (std::isfinite(mixed)) + samples[sample] = mixed; + else + { + samples[sample] = 0.0f; + ++nonFiniteCount; + } + } + + for (int channel = processedChannels; channel < numChannels; ++channel) + buffer.getWritePointer(channel)[sample] *= directLevel; + } + + diagnosticProcessedBlocks.fetch_add(1, std::memory_order_relaxed); + diagnosticProcessedSamples.fetch_add( + static_cast<std::uint64_t>(numSamples), + std::memory_order_relaxed); + diagnosticNonFiniteRecoveries.fetch_add( + nonFiniteCount, std::memory_order_relaxed); + diagnosticLastBlockDownPeak.store(downPeak, std::memory_order_relaxed); + diagnosticLastBlockUpPeak.store(upPeak, std::memory_order_relaxed); +} + +void NAMPolyOctaver::processVoicesForTesting( + const float* const* inputs, + float* const* octaveDownOutputs, + float* const* octaveUpOutputs, + int numChannels, + int numSamples) noexcept +{ + juce::ScopedNoDenormals noDenormals; + + synchroniseProfileTargets(); + + if (! prepared.load(std::memory_order_acquire) + || inputs == nullptr + || octaveDownOutputs == nullptr + || octaveUpOutputs == nullptr + || numSamples <= 0) + { + return; + } + + const int processedChannels = juce::jlimit( + 0, maximumChannels, numChannels); + std::uint64_t nonFiniteCount = 0; + float downPeak = 0.0f; + float upPeak = 0.0f; + for (int channel = 0; channel < processedChannels; ++channel) + { + if (inputs[channel] == nullptr + || octaveDownOutputs[channel] == nullptr + || octaveUpOutputs[channel] == nullptr) + { + ++nonFiniteCount; + continue; + } + + for (int sample = 0; sample < numSamples; ++sample) + { + const VoiceFrame voices = processVoiceSample( + channel, + inputs[channel][sample], + true, + true, + nonFiniteCount); + octaveDownOutputs[channel][sample] = voices.octaveDown; + octaveUpOutputs[channel][sample] = voices.octaveUp; + downPeak = juce::jmax(downPeak, std::abs(voices.octaveDown)); + upPeak = juce::jmax(upPeak, std::abs(voices.octaveUp)); + } + } + + diagnosticProcessedBlocks.fetch_add(1, std::memory_order_relaxed); + diagnosticProcessedSamples.fetch_add( + static_cast<std::uint64_t>(numSamples), + std::memory_order_relaxed); + diagnosticNonFiniteRecoveries.fetch_add( + nonFiniteCount, std::memory_order_relaxed); + diagnosticLastBlockDownPeak.store(downPeak, std::memory_order_relaxed); + diagnosticLastBlockUpPeak.store(upPeak, std::memory_order_relaxed); +} + +NAMPolyOctaver::Diagnostics NAMPolyOctaver::getDiagnostics() const noexcept +{ + Diagnostics result; + result.sampleRate = static_cast<double>( + diagnosticSampleRate.load(std::memory_order_relaxed)); + result.processingSampleRate = result.sampleRate + / static_cast<double>(resampleFactor); + result.preparedMaximumBlockSize = + diagnosticMaximumBlockSize.load(std::memory_order_relaxed); + result.activeBandCount = + diagnosticActiveBandCount.load(std::memory_order_relaxed); + result.octaveUpBandCount = + diagnosticOctaveUpBandCount.load(std::memory_order_relaxed); + result.instrumentProfile = requestedInstrumentProfile.load( + std::memory_order_relaxed) == 1 ? 1 : 0; + result.multirateFactor = resampleFactor; + result.lowestBandCentreHz = + diagnosticLowestBandCentreHz.load(std::memory_order_relaxed); + result.highestBandCentreHz = + diagnosticHighestBandCentreHz.load(std::memory_order_relaxed); + result.maximumGeneratedUpFrequencyHz = + 2.0f * result.highestBandCentreHz; + result.octaveUpPassbandHz = result.maximumGeneratedUpFrequencyHz; + result.octaveUpStopbandHz = juce::jmin( + static_cast<float>(result.sampleRate * 0.5), + nominalInterpolatorStopbandHz + * static_cast<float>(result.sampleRate / 48000.0)); + result.wetAntiAliasCutoffHz = + diagnosticWetAntiAliasCutoffHz.load(std::memory_order_relaxed); + result.wetAntiAliasOrder = wetAntiAliasOrder; + result.reportedLatencySamples = getLatencySamples(); + result.processedBlocks = + diagnosticProcessedBlocks.load(std::memory_order_relaxed); + result.processedSamples = + diagnosticProcessedSamples.load(std::memory_order_relaxed); + result.fastPathBlocks = + diagnosticFastPathBlocks.load(std::memory_order_relaxed); + result.nonFiniteRecoveries = + diagnosticNonFiniteRecoveries.load(std::memory_order_relaxed); + result.lastBlockDownPeak = + diagnosticLastBlockDownPeak.load(std::memory_order_relaxed); + result.lastBlockUpPeak = + diagnosticLastBlockUpPeak.load(std::memory_order_relaxed); + return result; +} + +void NAMPolyOctaver::resetDiagnostics() noexcept +{ + diagnosticProcessedBlocks.store(0, std::memory_order_relaxed); + diagnosticProcessedSamples.store(0, std::memory_order_relaxed); + diagnosticFastPathBlocks.store(0, std::memory_order_relaxed); + diagnosticNonFiniteRecoveries.store(0, std::memory_order_relaxed); + diagnosticLastBlockDownPeak.store(0.0f, std::memory_order_relaxed); + diagnosticLastBlockUpPeak.store(0.0f, std::memory_order_relaxed); +} + +NAMPolyOctaver::SelfTestResult NAMPolyOctaver::runDeterministicSelfTest( + double sampleRate) +{ + SelfTestResult result; + const double safeSampleRate = sampleRate > 1000.0 + ? sampleRate + : 48000.0; + constexpr double twoPi = 2.0 * juce::MathConstants<double>::pi; + + { + constexpr int sampleCount = 257; + juce::AudioBuffer<float> buffer(maximumChannels, sampleCount); + juce::AudioBuffer<float> reference(maximumChannels, sampleCount); + for (int channel = 0; channel < maximumChannels; ++channel) + { + auto* const samples = buffer.getWritePointer(channel); + for (int sample = 0; sample < sampleCount; ++sample) + { + const double time = static_cast<double>(sample) + / safeSampleRate; + samples[sample] = static_cast<float>( + 0.17 * std::sin(twoPi * (110.0 + 63.0 * channel) * time) + + 0.04 * std::sin( + twoPi * (701.0 + 296.0 * channel) * time + 0.2)); + } + } + reference.makeCopyOf(buffer); + NAMPolyOctaver bypass; + bypass.prepare(safeSampleRate, sampleCount); + bypass.processBlock(buffer); + result.exactBypassPassed = true; + for (int channel = 0; channel < maximumChannels; ++channel) + { + result.exactBypassPassed = result.exactBypassPassed + && std::memcmp( + buffer.getReadPointer(channel), + reference.getReadPointer(channel), + static_cast<std::size_t>(sampleCount) * sizeof(float)) == 0; + } + + NAMPolyOctaver silence; + silence.setLevels(0.0f, 0.0f, 0.0f); + silence.prepare(safeSampleRate, sampleCount); + silence.processBlock(reference); + result.exactSilencePassed = true; + for (int channel = 0; channel < maximumChannels; ++channel) + { + const auto* const samples = reference.getReadPointer(channel); + for (int sample = 0; sample < sampleCount; ++sample) + result.exactSilencePassed = result.exactSilencePassed + && samples[sample] == 0.0f; + } + } + + const int streamSampleCount = juce::jmax( + 8192, static_cast<int>(std::ceil(safeSampleRate * 0.4)) + 5); + std::vector<float> inputLeft( + static_cast<std::size_t>(streamSampleCount)); + std::vector<float> inputRight( + static_cast<std::size_t>(streamSampleCount)); + for (int sample = 0; sample < streamSampleCount; ++sample) + { + const double time = static_cast<double>(sample) / safeSampleRate; + inputLeft[static_cast<std::size_t>(sample)] = static_cast<float>( + 0.16 * std::sin(twoPi * 110.0 * time) + + 0.07 * std::sin(twoPi * 713.0 * time + 0.31)); + inputRight[static_cast<std::size_t>(sample)] = static_cast<float>( + 0.14 * std::sin(twoPi * 173.0 * time + 0.17) + + 0.05 * std::sin(twoPi * 997.0 * time + 0.83)); + } + + auto renderWholeStream = [&inputLeft, + &inputRight, + streamSampleCount]( + NAMPolyOctaver& processor, + std::array<std::vector<float>, maximumChannels>& down, + std::array<std::vector<float>, maximumChannels>& up) + { + for (int channel = 0; channel < maximumChannels; ++channel) + { + down[static_cast<std::size_t>(channel)].assign( + static_cast<std::size_t>(streamSampleCount), 0.0f); + up[static_cast<std::size_t>(channel)].assign( + static_cast<std::size_t>(streamSampleCount), 0.0f); + } + const float* inputChannels[] { inputLeft.data(), inputRight.data() }; + float* downChannels[] { down[0].data(), down[1].data() }; + float* upChannels[] { up[0].data(), up[1].data() }; + processor.processVoicesForTesting( + inputChannels, + downChannels, + upChannels, + maximumChannels, + streamSampleCount); + }; + + std::array<std::vector<float>, maximumChannels> resetDownA; + std::array<std::vector<float>, maximumChannels> resetUpA; + std::array<std::vector<float>, maximumChannels> resetDownB; + std::array<std::vector<float>, maximumChannels> resetUpB; + NAMPolyOctaver resetProcessor; + resetProcessor.prepare(safeSampleRate, 257); + renderWholeStream(resetProcessor, resetDownA, resetUpA); + resetProcessor.reset(); + renderWholeStream(resetProcessor, resetDownB, resetUpB); + result.maximumResetDifference = 0.0f; + for (int channel = 0; channel < maximumChannels; ++channel) + { + const auto channelIndex = static_cast<std::size_t>(channel); + result.maximumResetDifference = juce::jmax( + result.maximumResetDifference, + maximumAbsoluteDifference( + resetDownA[channelIndex], resetDownB[channelIndex])); + result.maximumResetDifference = juce::jmax( + result.maximumResetDifference, + maximumAbsoluteDifference( + resetUpA[channelIndex], resetUpB[channelIndex])); + } + result.resetDeterminismPassed = result.maximumResetDifference == 0.0f; + + NAMPolyOctaver partitionProcessor; + partitionProcessor.prepare(safeSampleRate, 257); + std::array<std::vector<float>, maximumChannels> partitionDown; + std::array<std::vector<float>, maximumChannels> partitionUp; + for (int channel = 0; channel < maximumChannels; ++channel) + { + partitionDown[static_cast<std::size_t>(channel)].assign( + static_cast<std::size_t>(streamSampleCount), 0.0f); + partitionUp[static_cast<std::size_t>(channel)].assign( + static_cast<std::size_t>(streamSampleCount), 0.0f); + } + constexpr std::array<int, 11> partitionPattern { + 1, 5, 7, 8, 13, 2, 31, 64, 3, 127, 11 + }; + int offset = 0; + std::size_t partitionIndex = 0; + while (offset < streamSampleCount) + { + const int count = juce::jmin( + partitionPattern[partitionIndex % partitionPattern.size()], + streamSampleCount - offset); + const float* inputChannels[] { + inputLeft.data() + offset, + inputRight.data() + offset + }; + float* downChannels[] { + partitionDown[0].data() + offset, + partitionDown[1].data() + offset + }; + float* upChannels[] { + partitionUp[0].data() + offset, + partitionUp[1].data() + offset + }; + partitionProcessor.processVoicesForTesting( + inputChannels, + downChannels, + upChannels, + maximumChannels, + count); + offset += count; + ++partitionIndex; + } + result.maximumPartitionDifference = 0.0f; + for (int channel = 0; channel < maximumChannels; ++channel) + { + const auto channelIndex = static_cast<std::size_t>(channel); + result.maximumPartitionDifference = juce::jmax( + result.maximumPartitionDifference, + maximumAbsoluteDifference( + resetDownA[channelIndex], partitionDown[channelIndex])); + result.maximumPartitionDifference = juce::jmax( + result.maximumPartitionDifference, + maximumAbsoluteDifference( + resetUpA[channelIndex], partitionUp[channelIndex])); + } + result.partitionInvariantPassed = + result.maximumPartitionDifference == 0.0f; + + { + NAMPolyOctaver stereoProcessor; + stereoProcessor.prepare(safeSampleRate, 257); + std::vector<float> silent( + static_cast<std::size_t>(streamSampleCount), 0.0f); + std::array<std::vector<float>, maximumChannels> down; + std::array<std::vector<float>, maximumChannels> up; + for (int channel = 0; channel < maximumChannels; ++channel) + { + down[static_cast<std::size_t>(channel)].assign( + static_cast<std::size_t>(streamSampleCount), 0.0f); + up[static_cast<std::size_t>(channel)].assign( + static_cast<std::size_t>(streamSampleCount), 0.0f); + } + const float* inputChannels[] { inputLeft.data(), silent.data() }; + float* downChannels[] { down[0].data(), down[1].data() }; + float* upChannels[] { up[0].data(), up[1].data() }; + stereoProcessor.processVoicesForTesting( + inputChannels, + downChannels, + upChannels, + maximumChannels, + streamSampleCount); + result.maximumSilentChannelLeak = juce::jmax( + maximumAbsoluteValue(down[1]), maximumAbsoluteValue(up[1])); + result.stereoIsolationPassed = result.maximumSilentChannelLeak == 0.0f; + + stereoProcessor.reset(); + const float* identicalInputs[] { inputLeft.data(), inputLeft.data() }; + stereoProcessor.processVoicesForTesting( + identicalInputs, + downChannels, + upChannels, + maximumChannels, + streamSampleCount); + result.maximumIdenticalStereoDifference = juce::jmax( + maximumAbsoluteDifference(down[0], down[1]), + maximumAbsoluteDifference(up[0], up[1])); + result.identicalStereoParityPassed = + result.maximumIdenticalStereoDifference == 0.0f; + } + + { + NAMPolyOctaver finiteProcessor; + finiteProcessor.prepare(safeSampleRate, 257); + std::vector<float> contaminatedLeft = inputLeft; + std::vector<float> contaminatedRight = inputRight; + contaminatedLeft[contaminatedLeft.size() / 3] + = std::numeric_limits<float>::quiet_NaN(); + contaminatedRight[contaminatedRight.size() / 2] + = std::numeric_limits<float>::infinity(); + std::array<std::vector<float>, maximumChannels> down; + std::array<std::vector<float>, maximumChannels> up; + for (int channel = 0; channel < maximumChannels; ++channel) + { + down[static_cast<std::size_t>(channel)].assign( + static_cast<std::size_t>(streamSampleCount), 0.0f); + up[static_cast<std::size_t>(channel)].assign( + static_cast<std::size_t>(streamSampleCount), 0.0f); + } + const float* inputChannels[] { + contaminatedLeft.data(), contaminatedRight.data() + }; + float* downChannels[] { down[0].data(), down[1].data() }; + float* upChannels[] { up[0].data(), up[1].data() }; + finiteProcessor.processVoicesForTesting( + inputChannels, + downChannels, + upChannels, + maximumChannels, + streamSampleCount); + bool outputsAreFinite = true; + for (int channel = 0; channel < maximumChannels; ++channel) + { + for (const float value : down[static_cast<std::size_t>(channel)]) + outputsAreFinite = outputsAreFinite && std::isfinite(value); + for (const float value : up[static_cast<std::size_t>(channel)]) + outputsAreFinite = outputsAreFinite && std::isfinite(value); + } + result.nonFiniteRecoveries = + finiteProcessor.getDiagnostics().nonFiniteRecoveries; + const std::size_t tailBegin = static_cast<std::size_t>( + streamSampleCount * 3 / 4); + const double tailRms = vectorRms(down[0], tailBegin) + + vectorRms(up[0], tailBegin) + + vectorRms(down[1], tailBegin) + + vectorRms(up[1], tailBegin); + result.finiteRecoveryPassed = outputsAreFinite + && result.nonFiniteRecoveries >= 2 + && tailRms > 1.0e-4; + } + + const int toneSampleCount = juce::jmax( + 4096, static_cast<int>(std::ceil(safeSampleRate * 1.5))); + const std::size_t toneAnalysisBegin = static_cast<std::size_t>( + juce::jlimit(0, toneSampleCount - 1, + static_cast<int>(std::ceil(safeSampleRate * 0.5)))); + auto renderTone = [safeSampleRate, toneSampleCount, twoPi]( + double frequency, + std::vector<float>& down, + std::vector<float>& up, + int instrumentProfile = 0) + { + std::vector<float> input(static_cast<std::size_t>(toneSampleCount)); + down.assign(static_cast<std::size_t>(toneSampleCount), 0.0f); + up.assign(static_cast<std::size_t>(toneSampleCount), 0.0f); + for (int sample = 0; sample < toneSampleCount; ++sample) + { + input[static_cast<std::size_t>(sample)] = static_cast<float>( + 0.2 * std::sin(twoPi * frequency + * static_cast<double>(sample) / safeSampleRate)); + } + NAMPolyOctaver processor; + processor.setInstrumentProfile(instrumentProfile); + processor.prepare(safeSampleRate, 257); + const float* inputChannels[] { input.data() }; + float* downChannels[] { down.data() }; + float* upChannels[] { up.data() }; + processor.processVoicesForTesting( + inputChannels, downChannels, upChannels, 1, toneSampleCount); + }; + + result.minimumTargetDominanceDb = + std::numeric_limits<float>::infinity(); + bool allTargetsPresent = true; + double validStopbandReferenceRms = 0.0; + for (const double frequency : { 110.0, 220.0, 440.0, 880.0 }) + { + std::vector<float> down; + std::vector<float> up; + renderTone(frequency, down, up); + const double downTarget = toneMagnitude( + down, toneAnalysisBegin, safeSampleRate, frequency * 0.5); + const double downResidual = toneMagnitude( + down, toneAnalysisBegin, safeSampleRate, frequency); + const double upTarget = toneMagnitude( + up, toneAnalysisBegin, safeSampleRate, frequency * 2.0); + const double upResidual = toneMagnitude( + up, toneAnalysisBegin, safeSampleRate, frequency); + const float downDominance = ratioToDecibels( + downTarget, downResidual); + const float upDominance = ratioToDecibels(upTarget, upResidual); + result.minimumTargetDominanceDb = juce::jmin( + result.minimumTargetDominanceDb, + juce::jmin(downDominance, upDominance)); + allTargetsPresent = allTargetsPresent + && downTarget > 0.02 + && upTarget > 0.02; + if (frequency == 880.0) + validStopbandReferenceRms = vectorRms(up, toneAnalysisBegin); + } + result.targetFrequencyPassed = allTargetsPresent + && result.minimumTargetDominanceDb >= 30.0f; + + result.minimumBassLowNoteDominanceDb = + std::numeric_limits<float>::infinity(); + bool allBassLowNotesPresent = true; + for (const double frequency : { 30.8677, 41.2034 }) + { + std::vector<float> down; + std::vector<float> up; + renderTone(frequency, down, up, 1); + const double downTarget = toneMagnitude( + down, toneAnalysisBegin, safeSampleRate, frequency * 0.5); + const double downResidual = toneMagnitude( + down, toneAnalysisBegin, safeSampleRate, frequency); + const double upTarget = toneMagnitude( + up, toneAnalysisBegin, safeSampleRate, frequency * 2.0); + const double upResidual = toneMagnitude( + up, toneAnalysisBegin, safeSampleRate, frequency); + result.minimumBassLowNoteDominanceDb = juce::jmin( + result.minimumBassLowNoteDominanceDb, + juce::jmin( + ratioToDecibels(downTarget, downResidual), + ratioToDecibels(upTarget, upResidual))); + allBassLowNotesPresent = allBassLowNotesPresent + && downTarget > 0.006 + && upTarget > 0.006; + } + result.bassLowNotePassed = allBassLowNotesPresent + && result.minimumBassLowNoteDominanceDb >= 24.0f; + + // Live Guitar->Bass->Guitar publication is exercised at hostile eight- + // sample and uneven callback boundaries. All 84 ERB states keep advancing + // regardless of profile, so the only difference is the 20 ms band-gain + // ramp and callback partitioning remains sample-identical. + const int switchSampleCount = juce::jmax( + 8192, static_cast<int>(safeSampleRate * 0.75)); + std::vector<float> switchInput( + static_cast<std::size_t>(switchSampleCount)); + for (int sample = 0; sample < switchSampleCount; ++sample) + { + switchInput[static_cast<std::size_t>(sample)] = static_cast<float>( + 0.18 * std::sin(twoPi * 41.2034 + * static_cast<double>(sample) / safeSampleRate)); + } + const int bassAt = switchSampleCount / 3; + const int guitarAt = switchSampleCount * 2 / 3; + auto renderProfileSwitch = [&] ( + const std::vector<int>& partitions, + std::vector<float>& output) + { + output.assign( + static_cast<std::size_t>(switchSampleCount), 0.0f); + NAMPolyOctaver processor; + processor.setLevels(0.0f, 0.72f, 0.45f); + processor.prepare(safeSampleRate, 257); + int offset = 0; + std::size_t partitionIndex = 0; + while (offset < switchSampleCount) + { + int count = partitions[partitionIndex % partitions.size()]; + count = juce::jmin(count, switchSampleCount - offset); + if (offset < bassAt && offset + count > bassAt) + count = bassAt - offset; + if (offset < guitarAt && offset + count > guitarAt) + count = guitarAt - offset; + if (offset == bassAt) + processor.setInstrumentProfile(1); + else if (offset == guitarAt) + processor.setInstrumentProfile(0); + const float* inputChannels[] { + switchInput.data() + offset + }; + std::vector<float> down(static_cast<std::size_t>(count)); + std::vector<float> up(static_cast<std::size_t>(count)); + float* downChannels[] { down.data() }; + float* upChannels[] { up.data() }; + processor.processVoicesForTesting( + inputChannels, downChannels, upChannels, 1, count); + for (int sample = 0; sample < count; ++sample) + { + output[static_cast<std::size_t>(offset + sample)] = + down[static_cast<std::size_t>(sample)] * 0.72f + + up[static_cast<std::size_t>(sample)] * 0.45f; + } + offset += count; + ++partitionIndex; + } + }; + std::vector<float> eightSampleSwitch; + std::vector<float> unevenSwitch; + renderProfileSwitch({ 8 }, eightSampleSwitch); + renderProfileSwitch( + { 1, 7, 3, 8, 2, 5, 8, 4 }, unevenSwitch); + result.maximumProfileSwitchPartitionDifference = + maximumAbsoluteDifference(eightSampleSwitch, unevenSwitch); + result.maximumProfileSwitchDelta = 0.0f; + bool switchOutputFinite = true; + for (std::size_t index = 1; index < eightSampleSwitch.size(); ++index) + { + switchOutputFinite = switchOutputFinite + && std::isfinite(eightSampleSwitch[index]); + result.maximumProfileSwitchDelta = juce::jmax( + result.maximumProfileSwitchDelta, + std::abs(eightSampleSwitch[index] + - eightSampleSwitch[index - 1])); + } + result.liveProfileSwitchPartitionPassed = + result.maximumProfileSwitchPartitionDifference == 0.0f; + result.liveProfileSwitchPassed = switchOutputFinite + && result.maximumProfileSwitchDelta < 0.20f; + + { + std::vector<float> rejectedDown; + std::vector<float> rejectedUp; + renderTone(safeSampleRate * 0.31, rejectedDown, rejectedUp); + const double rejectedRms = vectorRms( + rejectedUp, toneAnalysisBegin); + result.stopbandRejectionDb = ratioToDecibels( + rejectedRms, validStopbandReferenceRms); + result.stopbandRejectionPassed = + validStopbandReferenceRms > 1.0e-4 + && result.stopbandRejectionDb <= -70.0f; + } + + result.passed = result.exactBypassPassed + && result.exactSilencePassed + && result.resetDeterminismPassed + && result.partitionInvariantPassed + && result.stereoIsolationPassed + && result.identicalStereoParityPassed + && result.finiteRecoveryPassed + && result.targetFrequencyPassed + && result.bassLowNotePassed + && result.liveProfileSwitchPassed + && result.liveProfileSwitchPartitionPassed + && result.stopbandRejectionPassed; + return result; +} diff --git a/Source/NAMPolyOctaver.h b/Source/NAMPolyOctaver.h new file mode 100644 index 0000000..f4f05dd --- /dev/null +++ b/Source/NAMPolyOctaver.h @@ -0,0 +1,320 @@ +#pragma once + +#include <JuceHeader.h> + +#include <array> +#include <atomic> +#include <cstddef> +#include <cstdint> + +/** + * Stereo, fixed-ratio polyphonic octave generator for the NAM Rack. + * + * The DSP follows Steven Schulteis' MIT-licensed terrarium-poly-octave + * implementation of Etienne Thuillier's ERB-PS2 method: a 6:1 multirate + * front end, 80 complex ERB bands, and polyphase reconstruction. This host + * implementation keeps independent fixed state for two channels and carries + * the six-sample resampling phase across arbitrary callback partitions. + * + * prepare(), reset(), and parameter writes follow the normal AudioProcessor + * lifecycle. processBlock() performs no allocation, locking, logging, file + * I/O, coefficient design, or dynamic container growth. + */ +class NAMPolyOctaver final +{ +public: + static constexpr int maximumChannels = 2; + // Four sub-guitar ERB bands extend the same filter bank to 25.3 Hz for + // five-string bass B0. Guitar keeps the original 60 Hz first band and is + // therefore the bit-compatible default voicing. + static constexpr int bassExtendedBandCount = 4; + static constexpr int maximumBands = 84; + static constexpr int resampleFactor = 6; + + struct VoiceFrame + { + float octaveDown = 0.0f; + float octaveUp = 0.0f; + }; + + struct Diagnostics + { + double sampleRate = 0.0; + double processingSampleRate = 0.0; + int preparedMaximumBlockSize = 0; + int activeBandCount = 0; + int octaveUpBandCount = 0; + int instrumentProfile = 0; + int multirateFactor = 0; + float lowestBandCentreHz = 0.0f; + float highestBandCentreHz = 0.0f; + float octaveUpPassbandHz = 0.0f; + float octaveUpStopbandHz = 0.0f; + float wetAntiAliasCutoffHz = 0.0f; + int wetAntiAliasOrder = 0; + float maximumGeneratedUpFrequencyHz = 0.0f; + int reportedLatencySamples = 0; + std::uint64_t processedBlocks = 0; + std::uint64_t processedSamples = 0; + std::uint64_t fastPathBlocks = 0; + std::uint64_t nonFiniteRecoveries = 0; + float lastBlockDownPeak = 0.0f; + float lastBlockUpPeak = 0.0f; + }; + + struct SelfTestResult + { + bool passed = false; + bool exactBypassPassed = false; + bool exactSilencePassed = false; + bool resetDeterminismPassed = false; + bool partitionInvariantPassed = false; + bool stereoIsolationPassed = false; + bool identicalStereoParityPassed = false; + bool finiteRecoveryPassed = false; + bool targetFrequencyPassed = false; + bool bassLowNotePassed = false; + bool liveProfileSwitchPassed = false; + bool liveProfileSwitchPartitionPassed = false; + bool stopbandRejectionPassed = false; + float maximumResetDifference = 0.0f; + float maximumPartitionDifference = 0.0f; + float maximumSilentChannelLeak = 0.0f; + float maximumIdenticalStereoDifference = 0.0f; + float minimumTargetDominanceDb = 0.0f; + float minimumBassLowNoteDominanceDb = 0.0f; + float maximumProfileSwitchDelta = 0.0f; + float maximumProfileSwitchPartitionDifference = 0.0f; + float stopbandRejectionDb = 0.0f; + std::uint64_t nonFiniteRecoveries = 0; + }; + + NAMPolyOctaver() noexcept; + + /** Designs the sample-rate-dependent ERB bank and resets all history. */ + void prepare(double sampleRate, int maximumBlockSize) noexcept; + + /** Clears all phase/filter/resampling history deterministically. */ + void reset() noexcept; + + /** + * Sets independent linear gains. Values are clamped to [0, 1.25] and + * consumed through lock-free atomics at the next audio callback. A + * 20-millisecond sample-domain ramp is applied by processBlock(). + */ + void setLevels(float directLevel, + float octaveDownLevel, + float octaveUpLevel) noexcept; + + /** Selects 0=Guitar (60 Hz first ERB band) or 1=Bass (25.3 Hz). */ + void setInstrumentProfile(int profile) noexcept; + + /** In-place production mixer: Direct + Octave Down + Octave Up. */ + void processBlock(juce::AudioBuffer<float>& buffer) noexcept; + + /** + * Allocation-free raw-voice hook for deterministic headless tests. + * + * The input and output arrays must each contain numChannels valid channel + * pointers. numChannels is clamped to the supported mono/stereo range. + * This advances the exact production filter/resampling state but does not + * apply the Direct/Down/Up level smoothers. + */ + void processVoicesForTesting(const float* const* inputs, + float* const* octaveDownOutputs, + float* const* octaveUpOutputs, + int numChannels, + int numSamples) noexcept; + + [[nodiscard]] Diagnostics getDiagnostics() const noexcept; + void resetDiagnostics() noexcept; + + /** + * Runs allocation-using deterministic validation on the calling thread. + * This is a headless QA hook and must never be called from the audio + * callback. Subjective octave tone/voicing is deliberately not asserted. + */ + [[nodiscard]] static SelfTestResult runDeterministicSelfTest( + double sampleRate = 48000.0); + + [[nodiscard]] bool isPrepared() const noexcept + { + return prepared.load(std::memory_order_acquire); + } + + /** The wet path is causal; its FIR/chunk delay is deliberately not PDC. */ + [[nodiscard]] static constexpr int getLatencySamples() noexcept { return 0; } + +private: + struct ComplexValue + { + float real = 0.0f; + float imag = 0.0f; + }; + + struct BandCoefficients + { + float centreHz = 0.0f; + float bandwidthHz = 0.0f; + float d0 = 0.0f; + ComplexValue d1; + ComplexValue d2; + ComplexValue c1; + ComplexValue c2; + }; + + struct BandChannelState + { + ComplexValue state1; + ComplexValue state2; + ComplexValue previousBandOutput; + float octaveDownSign = 1.0f; + }; + + template <std::size_t size> + struct FixedRing + { + static_assert(size > 0 && (size & (size - 1)) == 0, + "FixedRing size must be a power of two"); + + void push(float value) noexcept + { + position = (position - 1U) & (size - 1U); + samples[position] = value; + } + + [[nodiscard]] float atAge(std::size_t age) const noexcept + { + return samples[(position + age) & (size - 1U)]; + } + + void clear() noexcept + { + samples.fill(0.0f); + position = 0; + } + + std::array<float, size> samples {}; + std::size_t position = 0; + }; + + struct DecimatorState + { + float process(const std::array<float, resampleFactor>& input) noexcept; + void reset() noexcept; + + [[nodiscard]] float stageOne() const noexcept; + [[nodiscard]] float stageTwo() const noexcept; + + FixedRing<32> fullRate; + FixedRing<16> oneThirdRate; + }; + + struct InterpolatorState + { + void process(float input, + std::array<float, resampleFactor>& output) noexcept; + void reset() noexcept; + + [[nodiscard]] float stageOneEven() const noexcept; + [[nodiscard]] float stageOneOdd() const noexcept; + [[nodiscard]] float stageTwoPhaseZero() const noexcept; + [[nodiscard]] float stageTwoPhaseOne() const noexcept; + [[nodiscard]] float stageTwoPhaseTwo() const noexcept; + + FixedRing<32> reducedRate; + FixedRing<16> oneThirdRate; + }; + + struct ChannelRateState + { + DecimatorState decimator; + InterpolatorState downInterpolator; + InterpolatorState upInterpolator; + std::array<float, resampleFactor> pendingInput {}; + std::array<float, resampleFactor> pendingDownOutput {}; + std::array<float, resampleFactor> pendingUpOutput {}; + int phase = 0; + bool downInterpolatorActive = false; + bool upInterpolatorActive = false; + float octaveDownDcInput = 0.0f; + float octaveDownDcOutput = 0.0f; + + void reset() noexcept; + }; + + static constexpr float levelRampSeconds = 0.020f; + static constexpr float phaseEnergyFloor = 1.0e-20f; + static constexpr int wetAntiAliasOrder = 34; + + static float bandCentreHz(int erbIndex) noexcept; + static float bandBandwidthHz(int erbIndex) noexcept; + static float fastInverseSqrt(float value) noexcept; + static float fastSqrt(float value) noexcept; + static ComplexValue multiply(ComplexValue left, + ComplexValue right) noexcept; + + void designFilterBank(double sampleRate) noexcept; + void resetDspState() noexcept; + void synchroniseLevelTargets() noexcept; + void synchroniseProfileTargets() noexcept; + VoiceFrame processVoiceSample(int channel, + float input, + bool generateOctaveDown, + bool generateOctaveUp, + std::uint64_t& nonFiniteCount) noexcept; + VoiceFrame processReducedRateSample(int channel, + float input, + bool generateOctaveDown, + bool generateOctaveUp, + std::uint64_t& nonFiniteCount) noexcept; + + [[nodiscard]] bool wetPathIsExactlySilent() const noexcept; + [[nodiscard]] bool directPathIsExactlyUnity() const noexcept; + [[nodiscard]] bool allPathsAreExactlySilent() const noexcept; + + std::array<BandCoefficients, maximumBands> bands {}; + std::array<std::array<BandChannelState, maximumBands>, maximumChannels> + channelStates {}; + std::array<ChannelRateState, maximumChannels> channelRateStates {}; + int activeBandCount = 0; + int octaveUpBandCount = 0; + bool dspStateIsReset = true; + + std::atomic<float> requestedDirectLevel { 1.0f }; + std::atomic<float> requestedOctaveDownLevel { 0.0f }; + std::atomic<float> requestedOctaveUpLevel { 0.0f }; + std::atomic<int> requestedInstrumentProfile { 0 }; + juce::SmoothedValue<float, juce::ValueSmoothingTypes::Linear> + smoothedDirectLevel; + juce::SmoothedValue<float, juce::ValueSmoothingTypes::Linear> + smoothedOctaveDownLevel; + juce::SmoothedValue<float, juce::ValueSmoothingTypes::Linear> + smoothedOctaveUpLevel; + std::array< + juce::SmoothedValue<float, juce::ValueSmoothingTypes::Linear>, + maximumChannels> smoothedBassProfile; + + std::atomic<bool> prepared { false }; + std::atomic<float> diagnosticSampleRate { 0.0f }; + std::atomic<int> diagnosticMaximumBlockSize { 0 }; + std::atomic<int> diagnosticActiveBandCount { 0 }; + std::atomic<int> diagnosticOctaveUpBandCount { 0 }; + float octaveDownHighPassCoefficient = 0.0f; + std::atomic<float> diagnosticLowestBandCentreHz { 0.0f }; + std::atomic<float> diagnosticHighestBandCentreHz { 0.0f }; + std::atomic<float> diagnosticWetAntiAliasCutoffHz { 0.0f }; + std::atomic<std::uint64_t> diagnosticProcessedBlocks { 0 }; + std::atomic<std::uint64_t> diagnosticProcessedSamples { 0 }; + std::atomic<std::uint64_t> diagnosticFastPathBlocks { 0 }; + std::atomic<std::uint64_t> diagnosticNonFiniteRecoveries { 0 }; + std::atomic<float> diagnosticLastBlockDownPeak { 0.0f }; + std::atomic<float> diagnosticLastBlockUpPeak { 0.0f }; + + static_assert(std::atomic<float>::is_always_lock_free, + "NAMPolyOctaver requires lock-free float atomics"); + static_assert(std::atomic<std::uint64_t>::is_always_lock_free, + "NAMPolyOctaver requires lock-free diagnostic counters"); + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(NAMPolyOctaver) +}; diff --git a/Source/OwnPitchEngine.cpp b/Source/OwnPitchEngine.cpp index 4a0dc3c..1936fb4 100644 --- a/Source/OwnPitchEngine.cpp +++ b/Source/OwnPitchEngine.cpp @@ -84,16 +84,19 @@ static bool writeOwnPitchLayerDumpWav ( } juce::WavAudioFormat format; - std::unique_ptr<juce::FileOutputStream> stream (file.createOutputStream()); + std::unique_ptr<juce::OutputStream> stream (file.createOutputStream()); if (stream == nullptr) return false; - std::unique_ptr<juce::AudioFormatWriter> writer ( - format.createWriterFor (stream.get(), sampleRate, static_cast<unsigned int> (numChannels), 24, {}, 0)); + auto writer = format.createWriterFor ( + stream, + juce::AudioFormatWriterOptions() + .withSampleRate (sampleRate) + .withNumChannels (numChannels) + .withBitsPerSample (24)); if (writer == nullptr) return false; - stream.release(); return writer->writeFromAudioSampleBuffer (buffer, 0, numSamples); } diff --git a/Source/PeakCache.h b/Source/PeakCache.h index aaf6bcb..d12037f 100644 --- a/Source/PeakCache.h +++ b/Source/PeakCache.h @@ -124,7 +124,11 @@ class PeakCache // Keep waveform generation serialized and low-impact; concurrent full-file // peak scans can steal disk/CPU from 32/64-sample monitoring callbacks. - juce::ThreadPool backgroundPool { 1 }; + juce::ThreadPool backgroundPool { + 1, + juce::Thread::osDefaultStackSize, + juce::Thread::Priority::low + }; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PeakCache) }; diff --git a/Source/PitchResynthesizer.cpp b/Source/PitchResynthesizer.cpp index 406232a..53f5647 100644 --- a/Source/PitchResynthesizer.cpp +++ b/Source/PitchResynthesizer.cpp @@ -138,16 +138,19 @@ static bool writePitchLayerDumpWav ( } juce::WavAudioFormat format; - std::unique_ptr<juce::FileOutputStream> stream (file.createOutputStream()); + std::unique_ptr<juce::OutputStream> stream (file.createOutputStream()); if (stream == nullptr) return false; - std::unique_ptr<juce::AudioFormatWriter> writer ( - format.createWriterFor (stream.get(), sampleRate, static_cast<unsigned int> (numChannels), 24, {}, 0)); + auto writer = format.createWriterFor ( + stream, + juce::AudioFormatWriterOptions() + .withSampleRate (sampleRate) + .withNumChannels (numChannels) + .withBitsPerSample (24)); if (writer == nullptr) return false; - stream.release(); return writer->writeFromAudioSampleBuffer (buffer, 0, numSamples); } diff --git a/Source/PitchResynthesizer.h b/Source/PitchResynthesizer.h index 0bad878..bd91809 100644 --- a/Source/PitchResynthesizer.h +++ b/Source/PitchResynthesizer.h @@ -9,7 +9,7 @@ * PitchResynthesizer — Offline pitch correction from graphical edits. * * Takes original audio + edited notes, produces corrected audio. - * Uses the Studio13 native VSF renderer for graphical offline pitch-only edits. + * Uses the OpenStudio native VSF renderer for graphical offline pitch-only edits. * * Non-destructive: original audio is never modified. */ diff --git a/Source/PlaybackEngine.cpp b/Source/PlaybackEngine.cpp index 1735e82..c8cd832 100644 --- a/Source/PlaybackEngine.cpp +++ b/Source/PlaybackEngine.cpp @@ -1,16 +1,70 @@ #include "PlaybackEngine.h" #include <algorithm> #include <cmath> +#include <thread> namespace { -constexpr bool kAudioPlaybackDebugLogs = false; +constexpr unsigned int kPitchRouteScrubPreview = 1u << 0; +constexpr unsigned int kPitchRouteClipLivePreview = 1u << 1; +constexpr unsigned int kPitchRouteRenderedSegment = 1u << 2; +constexpr unsigned int kPitchRouteCorrectedSource = 1u << 3; + +#ifndef OPENSTUDIO_AUDIO_PLAYBACK_DEBUG + #define OPENSTUDIO_AUDIO_PLAYBACK_DEBUG 0 +#endif +#if OPENSTUDIO_AUDIO_PLAYBACK_DEBUG +constexpr bool kAudioPlaybackDebugLogs = true; static void logAudioPlayback(const juce::String& message) { - if (kAudioPlaybackDebugLogs) - juce::Logger::writeToLog("[audio.playback] " + message); + juce::Logger::writeToLog("[audio.playback] " + message); } + #define OPENSTUDIO_LOG_AUDIO_PLAYBACK(message) logAudioPlayback(message) +#else +constexpr bool kAudioPlaybackDebugLogs = false; + #define OPENSTUDIO_LOG_AUDIO_PLAYBACK(message) do { } while (false) +#endif + +class StereoStreamingSourceReader final : public juce::AudioFormatReader +{ +public: + explicit StereoStreamingSourceReader(std::unique_ptr<juce::AudioFormatReader> sourceReader) + : juce::AudioFormatReader(nullptr, sourceReader->getFormatName()), + source(std::move(sourceReader)) + { + sampleRate = source->sampleRate; + lengthInSamples = source->lengthInSamples; + numChannels = juce::jmin(static_cast<unsigned int>(2), source->numChannels); + bitsPerSample = source->bitsPerSample; + usesFloatingPointData = source->usesFloatingPointData; + metadataValues = source->metadataValues; + } + + bool readSamples(int* const* destSamples, + int numDestChannels, + int startOffsetInDestBuffer, + juce::int64 startSampleInFile, + int numSamples) override + { + int* shiftedDestinations[2] { nullptr, nullptr }; + const int channelsToRead = juce::jlimit(0, 2, numDestChannels); + for (int channel = 0; channel < channelsToRead; ++channel) + { + if (destSamples[channel] != nullptr) + shiftedDestinations[channel] = destSamples[channel] + startOffsetInDestBuffer; + } + + return source->read(shiftedDestinations, + channelsToRead, + startSampleInFile, + numSamples, + true); + } + +private: + std::unique_ptr<juce::AudioFormatReader> source; +}; static float peakForBuffer(const juce::AudioBuffer<float>& buffer, int numSamples) { @@ -131,109 +185,944 @@ float PlaybackEngine::applyFadeCurve(float t, int curveType) } } +void PlaybackEngine::StreamingContinuityState::reset( + int channels) noexcept +{ + lastOutput.fill(0.0f); + concealedOutput.fill(0.0f); + expectedNextTimelineTime = 0.0; + recoverySamplesRemaining = 0; + activeChannels = juce::jlimit(0, 2, channels); + hasOutputHistory = false; + hasExpectedTimelineTime = false; + concealing = false; +} + +bool PlaybackEngine::StreamingContinuityState::beginBlock( + bool sourceReady, + bool timelineContiguous, + int channels) noexcept +{ + const int boundedChannels = + juce::jlimit(0, 2, channels); + if (! timelineContiguous + || activeChannels != boundedChannels) + { + reset(boundedChannels); + } + + if (! sourceReady) + { + if (! concealing) + { + for (int channel = 0; + channel < activeChannels; + ++channel) + { + const float previous = + hasOutputHistory + && std::isfinite(lastOutput[ + static_cast<size_t>(channel)]) + ? lastOutput[ + static_cast<size_t>(channel)] + : 0.0f; + concealedOutput[ + static_cast<size_t>(channel)] = + previous; + } + } + + concealing = true; + recoverySamplesRemaining = 0; + return false; + } + + if (concealing) + { + concealing = false; + recoverySamplesRemaining = + STREAMING_RECOVERY_SAMPLES; + return true; + } + + return false; +} + +float PlaybackEngine::StreamingContinuityState::processSample( + int channel, + float sourceSample, + bool sourceReady) noexcept +{ + if (channel < 0 || channel >= activeChannels) + return sourceReady ? sourceSample : 0.0f; + + const auto index = static_cast<size_t>(channel); + float output = sourceSample; + + if (! sourceReady) + { + concealedOutput[index] = + std::isfinite(concealedOutput[index]) + ? concealedOutput[index] + * STREAMING_CONCEALMENT_DECAY + : 0.0f; + output = concealedOutput[index]; + } + else if (recoverySamplesRemaining > 0) + { + concealedOutput[index] = + std::isfinite(concealedOutput[index]) + ? concealedOutput[index] + * STREAMING_CONCEALMENT_DECAY + : 0.0f; + const float recoveryProgress = + static_cast<float>( + STREAMING_RECOVERY_SAMPLES + - recoverySamplesRemaining + + 1) + / static_cast<float>( + STREAMING_RECOVERY_SAMPLES); + output = concealedOutput[index] + + recoveryProgress + * (sourceSample + - concealedOutput[index]); + } + + lastOutput[index] = + std::isfinite(output) ? output : 0.0f; + return lastOutput[index]; +} + +void PlaybackEngine::StreamingContinuityState::advanceFrame( + bool sourceReady) noexcept +{ + if (sourceReady + && recoverySamplesRemaining > 0) + { + --recoverySamplesRemaining; + if (recoverySamplesRemaining == 0) + concealedOutput = lastOutput; + } + + hasOutputHistory = true; +} + +PlaybackEngine::StreamingContinuityState& +PlaybackEngine::getTrackPlaybackContinuityState( + const juce::String& trackId) noexcept +{ + juce::int64 trackKey = trackId.hashCode64(); + if (trackKey == 0) + trackKey = 1; + + ++trackPlaybackContinuityUseCounter; + if (trackPlaybackContinuityUseCounter == 0) + trackPlaybackContinuityUseCounter = 1; + + const auto startIndex = + static_cast<size_t>( + static_cast<juce::uint64>(trackKey) + % TRACK_PLAYBACK_CONTINUITY_SLOT_COUNT); + size_t emptyIndex = + TRACK_PLAYBACK_CONTINUITY_SLOT_COUNT; + size_t oldestIndex = startIndex; + juce::uint64 oldestUse = + std::numeric_limits<juce::uint64>::max(); + + for (size_t probe = 0; + probe < TRACK_PLAYBACK_CONTINUITY_SLOT_COUNT; + ++probe) + { + const auto index = + (startIndex + probe) + % TRACK_PLAYBACK_CONTINUITY_SLOT_COUNT; + auto& slot = + trackPlaybackContinuitySlots[index]; + if (slot.trackKey == trackKey) + { + slot.lastUseCounter = + trackPlaybackContinuityUseCounter; + return slot.continuity; + } + if (slot.trackKey == 0 + && emptyIndex + == TRACK_PLAYBACK_CONTINUITY_SLOT_COUNT) + { + emptyIndex = index; + } + if (slot.lastUseCounter < oldestUse) + { + oldestUse = slot.lastUseCounter; + oldestIndex = index; + } + } + + const auto selectedIndex = + emptyIndex + < TRACK_PLAYBACK_CONTINUITY_SLOT_COUNT + ? emptyIndex + : oldestIndex; + auto& selected = + trackPlaybackContinuitySlots[ + selectedIndex]; + selected = {}; + selected.trackKey = trackKey; + selected.lastUseCounter = + trackPlaybackContinuityUseCounter; + return selected.continuity; +} + +PlaybackEngine::StreamingContinuityRegressionResult +PlaybackEngine::runStreamingContinuityRegression() noexcept +{ + auto prepareMiss = + [] (StreamingContinuityState& state) + { + state.reset(1); + state.beginBlock(true, false, 1); + for (int sample = 0; sample < 16; ++sample) + { + state.processSample( + 0, 0.8f, true); + state.advanceFrame(true); + } + + const float preMiss = + state.lastOutput[0]; + state.beginBlock(false, true, 1); + float firstConcealed = 0.0f; + for (int sample = 0; sample < 16; ++sample) + { + const float concealed = + state.processSample( + 0, 0.0f, false); + if (sample == 0) + firstConcealed = concealed; + state.advanceFrame(false); + } + return std::pair<float, float>( + std::abs(firstConcealed - preMiss), + state.lastOutput[0]); + }; + + StreamingContinuityState fixedState; + const auto fixedMiss = + prepareMiss(fixedState); + std::array<float, + STREAMING_RECOVERY_SAMPLES> + fixedRecovery {}; + fixedState.beginBlock(true, true, 1); + float fixedPrevious = fixedMiss.second; + float recoveryMaximumStep = 0.0f; + for (int sample = 0; + sample < STREAMING_RECOVERY_SAMPLES; + ++sample) + { + const float output = + fixedState.processSample( + 0, -0.8f, true); + fixedRecovery[ + static_cast<size_t>(sample)] = + output; + recoveryMaximumStep = + juce::jmax( + recoveryMaximumStep, + std::abs(output - fixedPrevious)); + fixedPrevious = output; + fixedState.advanceFrame(true); + } + + StreamingContinuityState partitionedState; + prepareMiss(partitionedState); + std::array<float, + STREAMING_RECOVERY_SAMPLES> + partitionedRecovery {}; + for (int block = 0; block < 4; ++block) + { + partitionedState.beginBlock( + true, true, 1); + for (int sample = 0; sample < 16; ++sample) + { + const auto outputIndex = + static_cast<size_t>( + block * 16 + sample); + partitionedRecovery[outputIndex] = + partitionedState.processSample( + 0, -0.8f, true); + partitionedState.advanceFrame(true); + } + } + + float partitionMaximumDifference = 0.0f; + for (size_t sample = 0; + sample < fixedRecovery.size(); + ++sample) + { + partitionMaximumDifference = + juce::jmax( + partitionMaximumDifference, + std::abs( + fixedRecovery[sample] + - partitionedRecovery[sample])); + } + + StreamingContinuityState fadeState; + prepareMiss(fadeState); + float fadeToZeroFinalSample = 0.0f; + fadeState.beginBlock(false, true, 1); + for (int sample = 0; sample < 16; ++sample) + { + const float rawConcealedSample = + fadeState.processSample( + 0, 0.0f, false); + const float currentFadeGain = + 1.0f + - static_cast<float>(sample + 1) + / 16.0f; + fadeToZeroFinalSample = + rawConcealedSample + * currentFadeGain; + fadeState.advanceFrame(false); + } + + StreamingContinuityRegressionResult result; + result.concealmentEntryStep = + fixedMiss.first; + result.recoveryMaximumStep = + recoveryMaximumStep; + result.partitionMaximumDifference = + partitionMaximumDifference; + result.recoveredSample = + fixedRecovery.back(); + result.fadeToZeroFinalSample = + fadeToZeroFinalSample; + result.passed = + result.concealmentEntryStep <= 0.001f + && result.recoveryMaximumStep <= 0.03f + && result.partitionMaximumDifference + <= 1.0e-7f + && std::abs( + result.recoveredSample + 0.8f) + <= 1.0e-6f + && std::abs( + result.fadeToZeroFinalSample) + <= 1.0e-7f; + return result; +} + +PlaybackEngine::OuterLockContinuityRegressionResult +PlaybackEngine::runOuterLockContinuityRegression() +{ + constexpr double fixtureSampleRate = 48000.0; + constexpr int missSamples = 16; + constexpr int recoverySamples = + STREAMING_RECOVERY_SAMPLES; + const juce::String trackId( + "outer-lock-continuity-fixture"); + + PlaybackEngine probe; + auto& continuity = + probe.getTrackPlaybackContinuityState( + trackId); + continuity.reset(1); + continuity.beginBlock(true, false, 1); + for (int sample = 0; + sample < missSamples; + ++sample) + { + continuity.processSample( + 0, 0.8f, true); + continuity.advanceFrame(true); + } + continuity.expectedNextTimelineTime = 0.0; + continuity.hasExpectedTimelineTime = true; + const float preMissSample = + continuity.lastOutput[0]; + + juce::AudioBuffer<float> concealed( + 1, missSamples); + concealed.clear(); + { + const juce::ScopedLock publicationGuard( + probe.lock); + std::thread callbackThread( + [&] + { + probe.fillTrackBuffer( + trackId, + concealed, + 0.0, + missSamples, + fixtureSampleRate); + }); + callbackThread.join(); + } + + juce::AudioBuffer<float> recovered( + 1, recoverySamples); + recovered.clear(); + probe.fillTrackBuffer( + trackId, + recovered, + static_cast<double>(missSamples) + / fixtureSampleRate, + recoverySamples, + fixtureSampleRate); + + OuterLockContinuityRegressionResult result; + result.tryLockMisses = + probe.getTryLockFailureCount(); + result.concealmentEvents = + probe + .getOuterLockContinuityConcealmentCount(); + result.recoveryEvents = + probe + .getOuterLockContinuityRecoveryCount(); + result.concealmentEntryStep = + std::abs( + concealed.getSample(0, 0) + - preMissSample); + result.recoveryEntryStep = + std::abs( + recovered.getSample(0, 0) + - concealed.getSample( + 0, missSamples - 1)); + result.recoveredSample = + recovered.getSample( + 0, recoverySamples - 1); + result.passed = + result.tryLockMisses == 1 + && result.concealmentEvents == 1 + && result.recoveryEvents == 1 + && result.concealmentEntryStep + <= 0.001f + && result.recoveryEntryStep + <= 0.02f + && std::abs(result.recoveredSample) + <= 1.0e-6f; + return result; +} + PlaybackEngine::PlaybackEngine() { formatManager.registerBasicFormats(); + streamingReadAheadThread.startThread(juce::Thread::Priority::normal); // Pre-allocate pitch-preview channel-pointer vectors (max stereo = 2 channels). // Avoids heap allocation inside fillTrackBuffer for every pitch-previewed clip. pitchPreviewInPtrs.resize (2); pitchPreviewOutPtrs.resize (2); + reusableChunkBoundaries.reserve (64); + reusableFileBuffer.setSize(2, 65536, false, true, false); + reusableTrackPlaybackBuffer.setSize( + 2, 65536, false, true, false); + pitchShiftWorkBuffer.setSize(2, 65536, false, true, false); } PlaybackEngine::~PlaybackEngine() { + streamingReadAheadThread.stopThread(2000); juce::ScopedLock sl(lock); readers.clear(); - audioDataCache.clear(); + streamingContinuityStates.clear(); + refreshStreamingReaderDiagnosticsLocked(); + fullyDecodedSources.clear(); + fullyDecodedSourceAccessTimes.clear(); + fullyDecodedBytesInUse = 0; + refreshFullyDecodedSourceDiagnosticsLocked(); clips.clear(); } -void PlaybackEngine::preloadReader(const juce::File& file) +void PlaybackEngine::refreshStreamingReaderDiagnosticsLocked() noexcept { - // Called from message thread — creates reader so audio thread never does disk I/O - juce::String filePath = file.getFullPathName(); - auto it = readers.find(filePath); - if (it != readers.end() && it->second != nullptr) - { - readerAccessTimes[filePath] = juce::Time::currentTimeMillis(); - return; // Already loaded - } + juce::int64 capacityBytes = 0; + constexpr juce::int64 bufferedFramesPerReader = + static_cast<juce::int64>(STREAMING_READ_AHEAD_SAMPLES) * 2; - std::unique_ptr<juce::AudioFormatReader> newReader(formatManager.createReaderFor(file)); - if (newReader) + for (const auto& [path, reader] : readers) { - readers[filePath] = std::move(newReader); - readerAccessTimes[filePath] = juce::Time::currentTimeMillis(); - evictOldReaders(); - juce::Logger::writeToLog("PlaybackEngine: Pre-loaded reader for: " + filePath); + juce::ignoreUnused(path); + if (reader != nullptr) + { + capacityBytes += bufferedFramesPerReader + * static_cast<juce::int64>(reader->numChannels) + * static_cast<juce::int64>(sizeof(float)); + } } + + cachedReaderCount.store(static_cast<int>(readers.size()), std::memory_order_release); + cachedStreamingReadAheadCapacityBytes.store(capacityBytes, std::memory_order_release); +} + +void PlaybackEngine::refreshFullyDecodedSourceDiagnosticsLocked() noexcept +{ + fullyDecodedSourceCount.store( + static_cast<int>(fullyDecodedSources.size()), + std::memory_order_release); + fullyDecodedSourceBytes.store( + fullyDecodedBytesInUse, + std::memory_order_release); +} + +void PlaybackEngine::refreshPitchPreviewRoutingDiagnosticsLocked() noexcept +{ + unsigned int flags = 0; + if (pitchScrubPreview.active || pitchScrubPreview.releasePending) + flags |= kPitchRouteScrubPreview; + if (!clipPitchPreviews.empty()) + flags |= kPitchRouteClipLivePreview; + if (!renderedPreviewSegments.empty()) + flags |= kPitchRouteRenderedSegment; + if (!pitchCorrectedFiles.empty()) + flags |= kPitchRouteCorrectedSource; + + pitchPreviewRoutingDiagnosticFlags.store(flags, std::memory_order_release); +} + +bool PlaybackEngine::primeStreamingReader(juce::BufferingAudioReader& reader, + double offsetSeconds, + int maxWaitMilliseconds) +{ + const int channels = juce::jmax(1, static_cast<int>(reader.numChannels)); + juce::AudioBuffer<float> scratch(channels, 1); + scratch.clear(); + const auto samplePosition = static_cast<juce::int64>( + juce::jmax(0.0, offsetSeconds) * reader.sampleRate); + if (maxWaitMilliseconds > 0) + reader.setReadTimeout(maxWaitMilliseconds); + const bool ready = reader.read(&scratch, 0, 1, samplePosition, true, true); + if (maxWaitMilliseconds > 0) + reader.setReadTimeout(0); + return ready; } -void PlaybackEngine::preloadAudioData(const juce::File& file, juce::AudioFormatReader& reader) +void PlaybackEngine::preloadReader(const juce::File& file, + const juce::String& readerKey, + double initialOffsetSeconds, + int maxWaitMilliseconds) { - constexpr juce::int64 maxCachedAudioBytesPerFile = 256LL * 1024LL * 1024LL; + // Reader construction and full-file decoding happen before taking the clip + // publication lock. The callback can keep the previously published state. const auto filePath = file.getFullPathName(); - const int channels = static_cast<int>(reader.numChannels); - const auto length = reader.lengthInSamples; - if (channels <= 0 || length <= 0 || length > std::numeric_limits<int>::max()) + const auto cacheKey = readerKey.isNotEmpty() ? readerKey : filePath; + std::shared_ptr<juce::BufferingAudioReader> existingReader; { - audioDataCache.erase(filePath); + const juce::ScopedLock sl(lock); + auto decoded = fullyDecodedSources.find(cacheKey); + if (decoded != fullyDecodedSources.end() + && decoded->second != nullptr) + { + fullyDecodedSourceAccessTimes[cacheKey] = + juce::Time::currentTimeMillis(); + return; + } + + auto existing = readers.find(cacheKey); + if (existing != readers.end() && existing->second != nullptr) + { + readerAccessTimes[cacheKey] = juce::Time::currentTimeMillis(); + existingReader = existing->second; + } + } + if (existingReader != nullptr) + { + // Published readers always keep timeout=0. This call only moves the + // background thread's requested position and cannot wait for decoding. + primeStreamingReader(*existingReader, initialOffsetSeconds, 0); return; } - const juce::int64 requiredBytes = length * static_cast<juce::int64>(channels) * static_cast<juce::int64>(sizeof(float)); - if (requiredBytes > maxCachedAudioBytesPerFile) + std::unique_ptr<juce::AudioFormatReader> sourceReader(formatManager.createReaderFor(file)); + if (sourceReader == nullptr) { - audioDataCache.erase(filePath); + juce::Logger::writeToLog("PlaybackEngine: Failed to create streaming reader for: " + filePath); return; } - auto cached = std::make_shared<CachedAudioData>(); - cached->sampleRate = reader.sampleRate; - cached->lengthInSamples = length; - cached->numChannels = channels; - cached->buffer.setSize(channels, static_cast<int>(length)); - if (reader.read(&cached->buffer, 0, static_cast<int>(length), 0, true, true)) - audioDataCache[filePath] = std::move(cached); - else - audioDataCache.erase(filePath); + const int decodedChannels = juce::jlimit( + 0, 2, static_cast<int>(sourceReader->numChannels)); + const auto sourceLength = sourceReader->lengthInSamples; + const auto bytesPerFrame = + static_cast<juce::int64>(decodedChannels) + * static_cast<juce::int64>(sizeof(float)); + const bool eligibleForFullDecode = + decodedChannels > 0 + && sourceReader->sampleRate > 0.0 + && sourceLength > 0 + && sourceLength + <= static_cast<juce::int64>( + std::numeric_limits<int>::max()) + && bytesPerFrame > 0 + && sourceLength + <= MAX_FULLY_DECODED_SOURCE_BYTES + / bytesPerFrame; + + if (eligibleForFullDecode) + { + auto decodedSource = + std::make_unique<FullyDecodedSource>(); + decodedSource->sampleRate = sourceReader->sampleRate; + decodedSource->lengthInSamples = sourceLength; + decodedSource->numChannels = decodedChannels; + decodedSource->decodedBytes = + sourceLength * bytesPerFrame; + decodedSource->samples.setSize( + decodedChannels, + static_cast<int>(sourceLength), + false, + true, + false); + + const bool decodedCompletely = sourceReader->read( + &decodedSource->samples, + 0, + static_cast<int>(sourceLength), + 0, + true, + true); + if (decodedCompletely) + { + bool publishedDecodedSource = false; + bool decodedSourceAlreadyPublished = false; + std::vector<std::unique_ptr<FullyDecodedSource>> + retiredSources; + { + const juce::ScopedLock sl(lock); + auto existingDecoded = + fullyDecodedSources.find(cacheKey); + decodedSourceAlreadyPublished = + existingDecoded + != fullyDecodedSources.end() + && existingDecoded->second != nullptr; + + if (! decodedSourceAlreadyPublished + && evictFullyDecodedSourcesToFitLocked( + decodedSource->decodedBytes, + cacheKey, + retiredSources)) + { + fullyDecodedBytesInUse += + decodedSource->decodedBytes; + fullyDecodedSources.emplace( + cacheKey, + std::move(decodedSource)); + fullyDecodedSourceAccessTimes[cacheKey] = + juce::Time::currentTimeMillis(); + refreshFullyDecodedSourceDiagnosticsLocked(); + publishedDecodedSource = true; + } + else if (decodedSourceAlreadyPublished) + { + fullyDecodedSourceAccessTimes[cacheKey] = + juce::Time::currentTimeMillis(); + } + } + + // Potentially large sample buffers are destroyed here, after the + // publication guard has been released. + retiredSources.clear(); + + if (publishedDecodedSource + || decodedSourceAlreadyPublished) + { + juce::Logger::writeToLog( + "PlaybackEngine: Prepared fully decoded source (" + + juce::String( + sourceLength * bytesPerFrame) + + " bytes): " + + filePath); + return; + } + + fullyDecodedSourceBudgetFallbackCount.fetch_add( + 1, std::memory_order_relaxed); + } + } + + // Large sources, corrupt/partial decodes, and decoded-cache budget misses + // retain the bounded streaming path. Its timeout remains zero once + // published, so the callback reports a cache miss rather than waiting. + auto stereoReader = std::make_unique<StereoStreamingSourceReader>(std::move(sourceReader)); + auto bufferedReader = std::make_shared<juce::BufferingAudioReader>( + stereoReader.release(), + streamingReadAheadThread, + STREAMING_READ_AHEAD_SAMPLES); + bufferedReader->setReadTimeout(0); + primeStreamingReader(*bufferedReader, initialOffsetSeconds, maxWaitMilliseconds); + + std::shared_ptr<juce::BufferingAudioReader> concurrentlyPublishedReader; + { + const juce::ScopedLock sl(lock); + auto [it, inserted] = readers.emplace(cacheKey, bufferedReader); + if (!inserted && it->second != nullptr) + concurrentlyPublishedReader = it->second; + streamingContinuityStates.try_emplace(cacheKey); + readerAccessTimes[cacheKey] = juce::Time::currentTimeMillis(); + evictOldReaders(cacheKey); + } + if (concurrentlyPublishedReader != nullptr) + primeStreamingReader(*concurrentlyPublishedReader, initialOffsetSeconds, 0); + + juce::Logger::writeToLog("PlaybackEngine: Prepared bounded streaming reader for: " + filePath); } -juce::AudioFormatReader* PlaybackEngine::getCachedReader(const juce::File& file) +juce::int64 PlaybackEngine::getStreamingReadAheadCapacityBytes() const { - // Audio-thread safe: only looks up, never creates readers - auto it = readers.find(file.getFullPathName()); + return cachedStreamingReadAheadCapacityBytes.load(std::memory_order_acquire); +} + +void PlaybackEngine::requestReadAheadAtTime(double timelineTimeSeconds) +{ + struct ReadAheadRequest + { + std::shared_ptr<juce::BufferingAudioReader> reader; + double offsetSeconds = 0.0; + }; + + std::vector<ReadAheadRequest> requests; + { + const juce::ScopedLock sl(lock); + requests.reserve(clips.size()); + constexpr double preRollSeconds = 2.0; + + for (const auto& clip : clips) + { + if (!clip.isActive + || timelineTimeSeconds >= clip.startTime + clip.duration + || timelineTimeSeconds + preRollSeconds < clip.startTime) + { + continue; + } + + const double clipTime = juce::jmax(0.0, timelineTimeSeconds - clip.startTime); + const juce::String* readerKey = &clip.readerKey; + double sourceOffset = clip.offset + clipTime; + + auto segmentIt = renderedPreviewSegments.find(clip.clipId); + if (segmentIt != renderedPreviewSegments.end()) + { + for (const auto& segment : segmentIt->second) + { + if (clipTime >= segment.startSec && clipTime < segment.endSec) + { + readerKey = &segment.readerKey; + sourceOffset = segment.fileOffsetSec + (clipTime - segment.startSec); + break; + } + } + } + + auto readerIt = readers.find(*readerKey); + if (readerIt != readers.end() && readerIt->second != nullptr) + requests.push_back({ readerIt->second, sourceOffset }); + } + } + + // Published readers permanently use timeout=0. These calls only publish a + // desired source position to JUCE's time-slice thread and never wait for I/O. + for (const auto& request : requests) + primeStreamingReader(*request.reader, request.offsetSeconds, 0); +} + +juce::BufferingAudioReader* PlaybackEngine::getCachedReader(const juce::String& readerKey) +{ + auto it = readers.find(readerKey); if (it != readers.end() && it->second != nullptr) return it->second.get(); return nullptr; } -void PlaybackEngine::evictOldReaders() +const PlaybackEngine::FullyDecodedSource* +PlaybackEngine::getFullyDecodedSource( + const juce::String& readerKey) const noexcept +{ + auto it = fullyDecodedSources.find(readerKey); + if (it != fullyDecodedSources.end() + && it->second != nullptr) + { + return it->second.get(); + } + return nullptr; +} + +bool PlaybackEngine::evictFullyDecodedSourcesToFitLocked( + juce::int64 requiredBytes, + const juce::String& protectedKey, + std::vector<std::unique_ptr<FullyDecodedSource>>& retiredSources) +{ + if (requiredBytes <= 0 + || requiredBytes > MAX_FULLY_DECODED_SOURCE_BYTES + || requiredBytes > MAX_FULLY_DECODED_CACHE_BYTES) + { + return false; + } + + if (fullyDecodedBytesInUse + <= MAX_FULLY_DECODED_CACHE_BYTES + - requiredBytes) + { + return true; + } + + std::vector<juce::String> referencedKeys; + referencedKeys.reserve( + clips.size() + renderedPreviewSegments.size()); + for (const auto& clip : clips) + { + if (clip.isActive + && clip.readerKey.isNotEmpty()) + { + referencedKeys.push_back(clip.readerKey); + } + } + for (const auto& [clipId, segments] : + renderedPreviewSegments) + { + juce::ignoreUnused(clipId); + for (const auto& segment : segments) + { + if (segment.readerKey.isNotEmpty()) + referencedKeys.push_back( + segment.readerKey); + } + } + + const auto isReferenced = + [&] (const juce::String& key) + { + return key == protectedKey + || std::find( + referencedKeys.begin(), + referencedKeys.end(), + key) != referencedKeys.end(); + }; + + std::vector< + std::pair<juce::int64, juce::String>> + candidates; + candidates.reserve( + fullyDecodedSourceAccessTimes.size()); + for (const auto& [key, accessTime] : + fullyDecodedSourceAccessTimes) + { + if (! isReferenced(key)) + candidates.push_back( + { accessTime, key }); + } + std::sort(candidates.begin(), candidates.end()); + + int evicted = 0; + for (const auto& [accessTime, key] : + candidates) + { + juce::ignoreUnused(accessTime); + if (fullyDecodedBytesInUse + <= MAX_FULLY_DECODED_CACHE_BYTES + - requiredBytes) + { + break; + } + + auto source = fullyDecodedSources.find(key); + if (source == fullyDecodedSources.end() + || source->second == nullptr) + { + fullyDecodedSourceAccessTimes.erase(key); + continue; + } + + fullyDecodedBytesInUse = + std::max<juce::int64>( + 0, + fullyDecodedBytesInUse + - source->second->decodedBytes); + retiredSources.push_back( + std::move(source->second)); + fullyDecodedSources.erase(source); + fullyDecodedSourceAccessTimes.erase(key); + ++evicted; + } + + if (evicted > 0) + { + fullyDecodedSourceEvictionCount.fetch_add( + evicted, std::memory_order_relaxed); + refreshFullyDecodedSourceDiagnosticsLocked(); + } + + return fullyDecodedBytesInUse + <= MAX_FULLY_DECODED_CACHE_BYTES + - requiredBytes; +} + +void PlaybackEngine::evictOldReaders(const juce::String& protectedKey) { if ((int)readers.size() <= MAX_CACHED_READERS) + { + refreshStreamingReaderDiagnosticsLocked(); return; + } - // Evict the oldest 25% by access time - int numToEvict = (int)readers.size() / 4; - if (numToEvict < 1) numToEvict = 1; + std::vector<juce::String> referencedKeys; + referencedKeys.reserve(clips.size() + renderedPreviewSegments.size()); + for (const auto& clip : clips) + { + if (clip.isActive && clip.readerKey.isNotEmpty()) + referencedKeys.push_back(clip.readerKey); + } + for (const auto& [clipId, segments] : renderedPreviewSegments) + { + juce::ignoreUnused(clipId); + for (const auto& segment : segments) + { + if (segment.readerKey.isNotEmpty()) + referencedKeys.push_back(segment.readerKey); + } + } + + const auto isReferenced = [&referencedKeys, &protectedKey](const juce::String& key) + { + return key == protectedKey + || std::find(referencedKeys.begin(), referencedKeys.end(), key) != referencedKeys.end(); + }; // Collect entries sorted by access time (oldest first) std::vector<std::pair<juce::int64, juce::String>> entries; for (const auto& [path, accessTime] : readerAccessTimes) - entries.push_back({ accessTime, path }); + { + if (!isReferenced(path)) + entries.push_back({ accessTime, path }); + } std::sort(entries.begin(), entries.end()); - for (int i = 0; i < numToEvict && i < (int)entries.size(); ++i) + const int desiredEvictions = static_cast<int>(readers.size()) - MAX_CACHED_READERS; + const int numToEvict = juce::jmin(desiredEvictions, static_cast<int>(entries.size())); + for (int i = 0; i < numToEvict; ++i) { const auto& path = entries[i].second; readers.erase(path); - audioDataCache.erase(path); + streamingContinuityStates.erase(path); readerAccessTimes.erase(path); } - juce::Logger::writeToLog("PlaybackEngine: Evicted " + juce::String(numToEvict) + " old readers"); + if (numToEvict > 0) + streamingReaderEvictionCount.fetch_add(numToEvict, std::memory_order_relaxed); + + if (static_cast<int>(readers.size()) > MAX_CACHED_READERS) + { + streamingReaderBudgetOvercommitCount.fetch_add(1, std::memory_order_relaxed); + juce::Logger::writeToLog( + "PlaybackEngine: Read-ahead target overcommitted to preserve active clips; readers=" + + juce::String(static_cast<int>(readers.size()))); + } + else if (numToEvict > 0) + { + juce::Logger::writeToLog("PlaybackEngine: Evicted " + juce::String(numToEvict) + + " inactive streaming readers"); + } + + refreshStreamingReaderDiagnosticsLocked(); } float PlaybackEngine::interpolateGainEnvelope(const std::vector<GainEnvelopePoint>& points, double time) @@ -277,8 +1166,6 @@ void PlaybackEngine::addClip(const juce::File& audioFile, double startTime, doub double offset, double volumeDB, double fadeIn, double fadeOut, const juce::String& clipId, const juce::File& sourceAudioFile, double sourceOffset) { - juce::ScopedLock sl(lock); - if (!audioFile.existsAsFile()) { juce::Logger::writeToLog("PlaybackEngine: Cannot add clip - file does not exist: " + audioFile.getFullPathName()); @@ -287,69 +1174,79 @@ void PlaybackEngine::addClip(const juce::File& audioFile, double startTime, doub juce::File effectiveFile = audioFile; double effectiveOffset = offset; - const bool hasActivePitchPreview = clipId.isNotEmpty() && clipPitchPreviews.find(clipId) != clipPitchPreviews.end(); - - // If a live pitch preview is active, always base playback on the original source. - // syncClipsWithBackend re-adds clips on every play, and the store's filePath may still - // point at an older corrected render. Reusing that here would make the live preview - // process already-corrected audio again, compounding pitch/formant on every play cycle. - if (hasActivePitchPreview && sourceAudioFile.existsAsFile()) + std::vector<RenderedPreviewSegment> segmentsToPreload; { - effectiveFile = sourceAudioFile; - effectiveOffset = sourceOffset >= 0.0 ? sourceOffset : offset; - juce::Logger::writeToLog("PlaybackEngine: Using original source for live preview clip " + clipId - + " -> " + effectiveFile.getFullPathName()); - } - else - { - // Check if this clip has a pitch-corrected file from a previous session. - // syncClipsWithBackend always re-adds clips with the original filePath from - // the frontend store, but the corrected file should be used for playback. - auto correctedIt = pitchCorrectedFiles.find(clipId); - if (correctedIt != pitchCorrectedFiles.end() && correctedIt->second.existsAsFile()) + const juce::ScopedLock sl(lock); + const bool hasActivePitchPreview = clipId.isNotEmpty() + && clipPitchPreviews.find(clipId) != clipPitchPreviews.end(); + + if (hasActivePitchPreview && sourceAudioFile.existsAsFile()) { - effectiveFile = correctedIt->second; - effectiveOffset = 0.0; // Corrected files always start at sample 0 - juce::Logger::writeToLog("PlaybackEngine: Using corrected file for clip " + clipId + effectiveFile = sourceAudioFile; + effectiveOffset = sourceOffset >= 0.0 ? sourceOffset : offset; + juce::Logger::writeToLog("PlaybackEngine: Using original source for live preview clip " + clipId + " -> " + effectiveFile.getFullPathName()); } - } - - // Pre-load the reader on the message thread so audio thread never does disk I/O - preloadReader(effectiveFile); - if (clipId.isNotEmpty()) - { - auto segmentIt = renderedPreviewSegments.find (clipId); - if (segmentIt != renderedPreviewSegments.end()) + else { - for (const auto& segment : segmentIt->second) + auto correctedIt = pitchCorrectedFiles.find(clipId); + if (correctedIt != pitchCorrectedFiles.end() && correctedIt->second.existsAsFile()) { - if (segment.audioFile.existsAsFile()) - preloadReader (segment.audioFile); + effectiveFile = correctedIt->second; + effectiveOffset = 0.0; + juce::Logger::writeToLog("PlaybackEngine: Using corrected file for clip " + clipId + + " -> " + effectiveFile.getFullPathName()); } } + + if (clipId.isNotEmpty()) + { + auto segmentIt = renderedPreviewSegments.find(clipId); + if (segmentIt != renderedPreviewSegments.end()) + segmentsToPreload = segmentIt->second; + } + } + + const auto logicalClipId = clipId.isNotEmpty() ? clipId : juce::Uuid().toString(); + const auto clipReaderKey = "clip|" + logicalClipId + "|" + effectiveFile.getFullPathName(); + + // Decoder construction and initial read-ahead occur without holding the + // clip publication lock. + preloadReader(effectiveFile, clipReaderKey, effectiveOffset); + for (const auto& segment : segmentsToPreload) + { + if (segment.audioFile.existsAsFile()) + preloadReader(segment.audioFile, + segment.readerKey, + segment.fileOffsetSec); } ClipInfo clip(effectiveFile, startTime, duration, trackId, effectiveOffset, volumeDB, fadeIn, fadeOut); clip.clipId = clipId; clip.envelopeKey = trackId + "::" + clipId; // Pre-compute to avoid string alloc on audio thread + clip.readerKey = clipReaderKey; clip.originalAudioFile = sourceAudioFile.existsAsFile() ? sourceAudioFile : audioFile; clip.originalOffset = sourceOffset >= 0.0 ? sourceOffset : offset; - clips.push_back(clip); + int totalClipCount = 0; + { + const juce::ScopedLock sl(lock); + clips.push_back(clip); + totalClipCount = static_cast<int>(clips.size()); + } juce::Logger::writeToLog("PlaybackEngine: Added clip - Track " + trackId + ", Start: " + juce::String(startTime) + "s, Duration: " + juce::String(duration) + "s, Offset: " + juce::String(offset) + "s, Volume: " + juce::String(volumeDB) + "dB"); - logAudioPlayback("addClip track=" + trackId + OPENSTUDIO_LOG_AUDIO_PLAYBACK("addClip track=" + trackId + " clipId=" + clipId + " file=" + effectiveFile.getFullPathName() + " originalFile=" + clip.originalAudioFile.getFullPathName() + " start=" + juce::String(startTime, 3) + " duration=" + juce::String(duration, 3) + " offset=" + juce::String(effectiveOffset, 3) - + " totalClips=" + juce::String(static_cast<int>(clips.size()))); + + " totalClips=" + juce::String(totalClipCount)); } void PlaybackEngine::removeClip(const juce::String& trackId, const juce::String& filePath) @@ -368,6 +1265,22 @@ void PlaybackEngine::removeClip(const juce::String& trackId, const juce::String& juce::Logger::writeToLog("PlaybackEngine: Removed clip from track " + trackId); } +void PlaybackEngine::removeClipById(const juce::String& trackId, const juce::String& clipId) +{ + juce::ScopedLock sl(lock); + + clips.erase( + std::remove_if(clips.begin(), clips.end(), + [&trackId, &clipId](const ClipInfo& clip) { + return clip.trackId == trackId && clip.clipId == clipId; + }), + clips.end() + ); + gainEnvelopes.erase(trackId + "::" + clipId); + + juce::Logger::writeToLog("PlaybackEngine: Removed clip " + clipId + " from track " + trackId); +} + void PlaybackEngine::replaceClipAudioFile(const juce::String& clipId, const juce::File& newFile) { if (!newFile.existsAsFile()) @@ -376,22 +1289,20 @@ void PlaybackEngine::replaceClipAudioFile(const juce::String& clipId, const juce return; } + const auto newReaderKey = "clip|" + clipId + "|" + newFile.getFullPathName(); + preloadReader(newFile, newReaderKey, 0.0); + juce::ScopedLock sl(lock); for (auto& clip : clips) { if (clip.clipId == clipId) { - // Evict old reader so the audio thread stops reading the old file - readers.erase(clip.audioFile.getFullPathName()); - audioDataCache.erase(clip.audioFile.getFullPathName()); - readerAccessTimes.erase(clip.audioFile.getFullPathName()); // Swap in the new file. Corrected files start at sample 0, but restoring // the original file should also restore the original trim offset. const bool restoringOriginal = (newFile == clip.originalAudioFile); clip.audioFile = newFile; clip.offset = restoringOriginal ? clip.originalOffset : 0.0; - // Pre-load new reader while we still hold the lock (same pattern as addClip) - preloadReader(newFile); + clip.readerKey = newReaderKey; // Clear any active pitch preview — the corrected audio is now baked // into the file, so the real-time PitchShifter must not double-shift. clipPitchPreviews.erase(clipId); @@ -412,6 +1323,7 @@ void PlaybackEngine::replaceClipAudioFile(const juce::String& clipId, const juce pitchCorrectedFiles.erase(clipId); else pitchCorrectedFiles[clipId] = newFile; + refreshPitchPreviewRoutingDiagnosticsLocked(); juce::Logger::writeToLog("PlaybackEngine: Replaced audio file for clip " + clipId + " -> " + newFile.getFullPathName()); return; @@ -434,6 +1346,12 @@ void PlaybackEngine::queueDeferredClipAudioFile(const juce::String& clipId, cons + " -> " + newFile.getFullPathName()); } +void PlaybackEngine::cancelDeferredClipAudioFile(const juce::String& clipId) +{ + juce::ScopedLock sl(lock); + deferredClipSwaps.erase(clipId); +} + bool PlaybackEngine::commitDeferredClipAudioFile(const juce::String& clipId) { juce::File fileToCommit; @@ -478,6 +1396,12 @@ bool PlaybackEngine::setClipRenderedPreviewSegment(const juce::String& clipId, return false; } + const auto segmentReaderKey = "segment|" + clipId + + "|" + juce::String(startSec, 9) + + "|" + juce::String(endSec, 9) + + "|" + audioFile.getFullPathName(); + preloadReader(audioFile, segmentReaderKey, fileOffsetSec); + juce::ScopedLock sl(lock); if (pitchCorrectedFiles.find(clipId) != pitchCorrectedFiles.end()) { @@ -498,7 +1422,6 @@ bool PlaybackEngine::setClipRenderedPreviewSegment(const juce::String& clipId, } } - preloadReader(audioFile); auto& segments = renderedPreviewSegments[clipId]; segments.erase(std::remove_if(segments.begin(), segments.end(), [startSec, endSec](const RenderedPreviewSegment& segment) @@ -509,10 +1432,11 @@ bool PlaybackEngine::setClipRenderedPreviewSegment(const juce::String& clipId, return sameWindow || overlaps; }), segments.end()); - segments.push_back({ audioFile, startSec, endSec, fileOffsetSec }); + segments.push_back({ audioFile, startSec, endSec, fileOffsetSec, segmentReaderKey }); std::sort(segments.begin(), segments.end(), [] (const RenderedPreviewSegment& a, const RenderedPreviewSegment& b) { return a.startSec < b.startSec; }); + refreshPitchPreviewRoutingDiagnosticsLocked(); juce::Logger::writeToLog("PlaybackEngine: Set rendered preview segment for clip " + clipId + " [" + juce::String(startSec, 3) + ", " + juce::String(endSec, 3) + "]" + " fileOffset=" + juce::String(fileOffsetSec, 3) @@ -526,6 +1450,7 @@ void PlaybackEngine::beginRenderedPreviewSegmentGeneration(const juce::String& c juce::ScopedLock sl(lock); renderedPreviewSegments.erase(clipId); renderedPreviewSegmentGenerations[clipId] = generation; + refreshPitchPreviewRoutingDiagnosticsLocked(); juce::Logger::writeToLog("PlaybackEngine: Began rendered preview generation for clip " + clipId + " generation=" + juce::String(generation)); } @@ -538,6 +1463,7 @@ void PlaybackEngine::invalidateRenderedPreviewSegments(const juce::String& clipI ++generation; if (generation <= 0) generation = 1; + refreshPitchPreviewRoutingDiagnosticsLocked(); juce::Logger::writeToLog("PlaybackEngine: Invalidated rendered preview segments for clip " + clipId + " generation=" + juce::String(generation)); } @@ -550,6 +1476,7 @@ void PlaybackEngine::clearClipRenderedPreviewSegments(const juce::String& clipId ++generation; if (generation <= 0) generation = 1; + refreshPitchPreviewRoutingDiagnosticsLocked(); juce::Logger::writeToLog("PlaybackEngine: Cleared rendered preview segments for clip " + clipId + " generation=" + juce::String(generation)); } @@ -572,6 +1499,7 @@ void PlaybackEngine::clearAllPitchPreviewRoutes(const juce::String& clipId) pitchScrubPreview = {}; pitchScrubPreviewStatus = {}; pitchScrubStretcherPrepared = false; + refreshPitchPreviewRoutingDiagnosticsLocked(); juce::Logger::writeToLog("PlaybackEngine: Hard-cleared all pitch preview routes"); return; } @@ -590,6 +1518,7 @@ void PlaybackEngine::clearAllPitchPreviewRoutes(const juce::String& clipId) pitchScrubStretcherPrepared = false; } + refreshPitchPreviewRoutingDiagnosticsLocked(); juce::Logger::writeToLog("PlaybackEngine: Hard-cleared pitch preview routes for clip " + clipId + " generation=" + juce::String(generation)); } @@ -625,6 +1554,7 @@ int PlaybackEngine::clearPitchPreviewRoutesForCorrectedSources() if (cleared > 0) juce::Logger::writeToLog("PlaybackEngine: Hard-cleared pitch preview routes for " + juce::String(cleared) + " corrected-source clip(s)"); + refreshPitchPreviewRoutingDiagnosticsLocked(); return cleared; } @@ -704,19 +1634,27 @@ void PlaybackEngine::clearPitchCorrectionFile(const juce::String& clipId) pitchScrubStretcherPrepared = false; } deferredClipSwaps.erase(clipId); + refreshPitchPreviewRoutingDiagnosticsLocked(); juce::Logger::writeToLog("PlaybackEngine: Cleared pitch correction file for clip " + clipId); } void PlaybackEngine::clearAllClips() { juce::ScopedLock sl(lock); +#if OPENSTUDIO_AUDIO_PLAYBACK_DEBUG const int previousClipCount = static_cast<int>(clips.size()); const int preservedPreviewCount = static_cast<int>(clipPitchPreviews.size()); const int preservedCorrectedCount = static_cast<int>(pitchCorrectedFiles.size()); +#endif clips.clear(); readers.clear(); - audioDataCache.clear(); + streamingContinuityStates.clear(); readerAccessTimes.clear(); + refreshStreamingReaderDiagnosticsLocked(); + fullyDecodedSources.clear(); + fullyDecodedSourceAccessTimes.clear(); + fullyDecodedBytesInUse = 0; + refreshFullyDecodedSourceDiagnosticsLocked(); // NOTE: clipPitchPreviews is NOT cleared here — it must survive sync cycles. // syncClipsWithBackend calls clearAllClips + re-adds clips, and the preview // must persist so the user continues hearing edited notes across play cycles. @@ -726,7 +1664,7 @@ void PlaybackEngine::clearAllClips() lagrangeInterpolatorR.reset(); juce::Logger::writeToLog("PlaybackEngine: Cleared all clips (pitch previews preserved: " + juce::String(static_cast<int>(clipPitchPreviews.size())) + ")"); - logAudioPlayback("clearAllClips previousClipCount=" + juce::String(previousClipCount) + OPENSTUDIO_LOG_AUDIO_PLAYBACK("clearAllClips previousClipCount=" + juce::String(previousClipCount) + " preservedPreviews=" + juce::String(preservedPreviewCount) + " preservedCorrectedFiles=" + juce::String(preservedCorrectedCount)); } @@ -766,35 +1704,15 @@ std::vector<PlaybackEngine::ClipInfo> PlaybackEngine::getClipSnapshot() const return clips; } -juce::AudioFormatReader* PlaybackEngine::getReader(const juce::File& file) -{ - juce::String filePath = file.getFullPathName(); - - // Check if reader already exists - auto it = readers.find(filePath); - if (it != readers.end() && it->second != nullptr) - return it->second.get(); - - // Create new reader - std::unique_ptr<juce::AudioFormatReader> newReader(formatManager.createReaderFor(file)); - if (newReader == nullptr) - { - juce::Logger::writeToLog("PlaybackEngine: Failed to create reader for: " + filePath); - return nullptr; - } - - auto* readerPtr = newReader.get(); - readers[filePath] = std::move(newReader); - - juce::Logger::writeToLog("PlaybackEngine: Created reader for: " + filePath); - return readerPtr; -} - // ---- Pitch preview methods ---- void PlaybackEngine::setClipPitchPreview (const juce::String& clipId, const ClipPitchPreviewData& preview) { + juce::File originalFileToPreload; + juce::String originalReaderKey; + double originalOffsetToPreload = 0.0; + { juce::ScopedLock sl (lock); // If the clip currently has a pitch-corrected file baked in, revert to the @@ -814,19 +1732,21 @@ void PlaybackEngine::setClipPitchPreview (const juce::String& clipId, { if (clip.clipId == clipId && clip.originalAudioFile.existsAsFile()) { + const auto desiredReaderKey = "clip|" + clipId + + "|" + clip.originalAudioFile.getFullPathName(); const bool usingOriginalAlready = (clip.audioFile == clip.originalAudioFile) && std::abs (clip.offset - clip.originalOffset) < 0.0005; if (! usingOriginalAlready) { - readers.erase (clip.audioFile.getFullPathName()); - audioDataCache.erase (clip.audioFile.getFullPathName()); - readerAccessTimes.erase (clip.audioFile.getFullPathName()); clip.audioFile = clip.originalAudioFile; clip.offset = clip.originalOffset; - preloadReader (clip.audioFile); juce::Logger::writeToLog ("PlaybackEngine: Reverted clip " + clipId + " to original file for preview"); } + clip.readerKey = desiredReaderKey; + originalFileToPreload = clip.originalAudioFile; + originalReaderKey = desiredReaderKey; + originalOffsetToPreload = clip.originalOffset; break; } } @@ -858,12 +1778,20 @@ void PlaybackEngine::setClipPitchPreview (const juce::String& clipId, + " liveFormantSuppressed=" + juce::String (std::abs (preview.globalFormantSemitones) > 0.01f ? "yes" : "no") + " window=[" + juce::String (preview.previewStartSec, 3) + "," + juce::String (preview.previewEndSec, 3) + "]"); + refreshPitchPreviewRoutingDiagnosticsLocked(); + } + + if (originalFileToPreload.existsAsFile()) + preloadReader(originalFileToPreload, + originalReaderKey, + originalOffsetToPreload); } void PlaybackEngine::clearClipPitchPreview (const juce::String& clipId) { juce::ScopedLock sl (lock); clipPitchPreviews.erase (clipId); + refreshPitchPreviewRoutingDiagnosticsLocked(); juce::Logger::writeToLog ("PlaybackEngine: Cleared pitch preview for clip " + clipId); } @@ -879,6 +1807,9 @@ void PlaybackEngine::setPitchScrubPreview (const PitchScrubPreviewData& preview) pitchScrubPreview.active = preview.loopBuffer.getNumSamples() > 8 && preview.loopBuffer.getNumChannels() > 0 && preview.pitchRatio > 0.0f; + pitchScrubPreviewMayRender.store( + pitchScrubPreview.active, + std::memory_order_release); pitchScrubPreview.readPosition = 0.0; pitchScrubPreview.currentGain = 0.0f; pitchScrubPreview.targetGain = juce::jlimit (0.0f, 2.0f, preview.gain); @@ -912,7 +1843,8 @@ void PlaybackEngine::setPitchScrubPreview (const PitchScrubPreviewData& preview) pitchScrubInputBuffer.setSize (juce::jmax (1, preview.loopBuffer.getNumChannels()), preloadSamples, false, true, true); pitchScrubOutputBuffer.setSize (juce::jmax (1, preview.loopBuffer.getNumChannels()), preloadSamples, false, true, true); - logAudioPlayback ("setPitchScrubPreview clip=" + preview.clipId + refreshPitchPreviewRoutingDiagnosticsLocked(); + OPENSTUDIO_LOG_AUDIO_PLAYBACK("setPitchScrubPreview clip=" + preview.clipId + " track=" + preview.trackId + " samples=" + juce::String (preview.loopBuffer.getNumSamples()) + " channels=" + juce::String (preview.loopBuffer.getNumChannels()) @@ -941,7 +1873,8 @@ void PlaybackEngine::clearPitchScrubPreview (const juce::String& clipId) pitchScrubPreview.releasePending = true; pitchScrubPreview.targetGain = 0.0f; pitchScrubPreviewStatus.releasePending = true; - logAudioPlayback ("clearPitchScrubPreview clip=" + clipId); + refreshPitchPreviewRoutingDiagnosticsLocked(); + OPENSTUDIO_LOG_AUDIO_PLAYBACK("clearPitchScrubPreview clip=" + clipId); } } @@ -965,7 +1898,10 @@ void PlaybackEngine::renderPitchScrubPreview (juce::AudioBuffer<float>& buffer, || pitchScrubPreview.loopBuffer.getNumSamples() <= 8 || pitchScrubPreview.loopBuffer.getNumChannels() <= 0 || sampleRate <= 0.0) + { + pitchScrubPreviewMayRender.store(false, std::memory_order_release); return; + } const auto playbackRatio = (pitchScrubPreview.sourceSampleRate > 0.0) ? (pitchScrubPreview.sourceSampleRate / sampleRate) @@ -1088,6 +2024,8 @@ void PlaybackEngine::renderPitchScrubPreview (juce::AudioBuffer<float>& buffer, pitchScrubPreviewStatus.releasePending = false; pitchScrubPreviewStatus.previewArmed = false; pitchScrubStretcherPrepared = false; + pitchScrubPreviewMayRender.store(false, std::memory_order_release); + refreshPitchPreviewRoutingDiagnosticsLocked(); } } #if defined(_MSC_VER) @@ -1104,20 +2042,36 @@ PlaybackEngine::PitchScrubPreviewStatus PlaybackEngine::getPitchScrubPreviewStat PlaybackEngine::PitchPreviewRoutingStatus PlaybackEngine::getPitchPreviewRoutingStatus (const juce::String& clipId) const { + if (clipId.isEmpty()) + { + const auto diagnostic = getPitchPreviewRoutingDiagnosticStatus(); + PitchPreviewRoutingStatus status; + status.scrubPreviewActive = diagnostic.scrubPreviewActive; + status.clipLivePreviewActive = diagnostic.clipLivePreviewActive; + status.renderedSegmentActive = diagnostic.renderedSegmentActive; + status.correctedSourceActive = diagnostic.correctedSourceActive; + + if (status.renderedSegmentActive) + status.monitorMode = "rendered_segment"; + else if (status.scrubPreviewActive) + status.monitorMode = "scrub"; + else if (status.clipLivePreviewActive) + status.monitorMode = "clip_live_preview"; + else if (status.correctedSourceActive) + status.monitorMode = "corrected_source"; + else + status.monitorMode = "none"; + + return status; + } + const juce::ScopedLock sl (lock); PitchPreviewRoutingStatus status; - const bool queryAll = clipId.isEmpty(); status.scrubPreviewActive = (pitchScrubPreview.active || pitchScrubPreview.releasePending) - && (queryAll || pitchScrubPreview.clipId == clipId); - status.clipLivePreviewActive = queryAll - ? ! clipPitchPreviews.empty() - : clipPitchPreviews.find (clipId) != clipPitchPreviews.end(); - status.renderedSegmentActive = queryAll - ? ! renderedPreviewSegments.empty() - : renderedPreviewSegments.find (clipId) != renderedPreviewSegments.end(); - status.correctedSourceActive = queryAll - ? ! pitchCorrectedFiles.empty() - : pitchCorrectedFiles.find (clipId) != pitchCorrectedFiles.end(); + && pitchScrubPreview.clipId == clipId; + status.clipLivePreviewActive = clipPitchPreviews.find (clipId) != clipPitchPreviews.end(); + status.renderedSegmentActive = renderedPreviewSegments.find (clipId) != renderedPreviewSegments.end(); + status.correctedSourceActive = pitchCorrectedFiles.find (clipId) != pitchCorrectedFiles.end(); if (status.renderedSegmentActive) status.monitorMode = "rendered_segment"; @@ -1133,6 +2087,18 @@ PlaybackEngine::PitchPreviewRoutingStatus PlaybackEngine::getPitchPreviewRouting return status; } +PlaybackEngine::PitchPreviewRoutingDiagnosticStatus +PlaybackEngine::getPitchPreviewRoutingDiagnosticStatus() const noexcept +{ + const auto flags = pitchPreviewRoutingDiagnosticFlags.load(std::memory_order_acquire); + PitchPreviewRoutingDiagnosticStatus status; + status.scrubPreviewActive = (flags & kPitchRouteScrubPreview) != 0; + status.clipLivePreviewActive = (flags & kPitchRouteClipLivePreview) != 0; + status.renderedSegmentActive = (flags & kPitchRouteRenderedSegment) != 0; + status.correctedSourceActive = (flags & kPitchRouteCorrectedSource) != 0; + return status; +} + float PlaybackEngine::lookupPitchRatio (const std::vector<PitchCorrectionSegment>& segments, double timeInClip) { // Binary search could be used for large segment lists, but linear is fine for typical note counts @@ -1154,27 +2120,112 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, int numSamples, double sampleRate) { - // Use TryLock to avoid blocking the audio thread (REAPER-style). - // If the lock is held (message thread adding/removing clips), we return silence. - // This is extremely rare and inaudible — same pattern as AudioRecorder::writeBlock. + if (numSamples <= 0 + || sampleRate <= 0.0 + || buffer.getNumChannels() <= 0) + { + return; + } + + const int playbackOutputChannels = + juce::jmin( + buffer.getNumChannels(), + reusableTrackPlaybackBuffer + .getNumChannels()); + auto& trackContinuity = + getTrackPlaybackContinuityState(trackId); + const double continuityTolerance = + 2.0 / sampleRate; + const bool timelineContiguous = + trackContinuity.hasExpectedTimelineTime + && std::abs( + currentTime + - trackContinuity + .expectedNextTimelineTime) + <= continuityTolerance; + + // Never wait behind clip/edit/pitch publication. On contention, conceal + // only the missing playback contribution; sends and live input already in + // the destination remain untouched. const juce::ScopedTryLock sl(lock); if (!sl.isLocked()) { - const int tryLockMiss = tryLockFailureCount.fetch_add(1, std::memory_order_relaxed) + 1; + const int tryLockMiss = + tryLockFailureCount.fetch_add( + 1, std::memory_order_relaxed) + + 1; + trackContinuity.beginBlock( + false, + timelineContiguous, + playbackOutputChannels); + float concealedPeak = 0.0f; + for (int sample = 0; + sample < numSamples; + ++sample) + { + for (int channel = 0; + channel < playbackOutputChannels; + ++channel) + { + const float concealed = + trackContinuity.processSample( + channel, 0.0f, false); + buffer.addSample( + channel, sample, concealed); + concealedPeak = + juce::jmax( + concealedPeak, + std::abs(concealed)); + } + trackContinuity.advanceFrame(false); + } + trackContinuity.expectedNextTimelineTime = + currentTime + + static_cast<double>(numSamples) + / sampleRate; + trackContinuity.hasExpectedTimelineTime = + true; + outerLockContinuityConcealmentCount + .fetch_add( + 1, std::memory_order_relaxed); + outerLockContinuityConcealedSampleCount + .fetch_add( + static_cast<juce::int64>( + numSamples) + * static_cast<juce::int64>( + playbackOutputChannels), + std::memory_order_relaxed); + lastTrackPlaybackPeak.store( + concealedPeak, + std::memory_order_relaxed); if ((tryLockMiss % 20) == 1) - logAudioPlayback("fillTrackBuffer lock miss track=" + trackId + OPENSTUDIO_LOG_AUDIO_PLAYBACK("fillTrackBuffer lock miss track=" + trackId + " currentTime=" + juce::String(currentTime, 3) + " count=" + juce::String(tryLockMiss)); - buffer.clear(); return; } - buffer.clear(); + if (playbackOutputChannels <= 0 + || reusableTrackPlaybackBuffer + .getNumSamples() + < numSamples) + { + // The callback cannot resize this scratch safely. The normal device + // maximum is far below the 65536-frame prepared capacity. + fileBufferResizeCount.fetch_add( + 1, std::memory_order_relaxed); + return; + } + reusableTrackPlaybackBuffer.clear( + 0, numSamples); + auto& playbackMix = + reusableTrackPlaybackBuffer; + int overlappingClipCount = 0; int mixedClipCount = 0; static std::atomic<int> fillTrackBufferCallCounter { 0 }; const int fillCall = fillTrackBufferCallCounter.fetch_add(1, std::memory_order_relaxed) + 1; - const bool shouldLogDetailed = (fillCall % 50) == 1; + const bool shouldLogDetailed = kAudioPlaybackDebugLogs && (fillCall % 50) == 1; double windowEnd = currentTime + (numSamples / sampleRate); @@ -1244,30 +2295,51 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, } auto mixChunk = [&] (int outputStart, int requestedOutputSamples, - const juce::File& playbackFile, double playbackOffset, + const juce::File& playbackFile, + const juce::String& readerKey, + double playbackOffset, bool usingRenderedPreviewSegment, bool usingCorrectedSource) { + juce::ignoreUnused(playbackFile); if (requestedOutputSamples <= 0) return false; - const auto cacheIt = audioDataCache.find(playbackFile.getFullPathName()); - const auto cachedAudio = cacheIt != audioDataCache.end() ? cacheIt->second : nullptr; - auto* reader = cachedAudio ? nullptr : getCachedReader (playbackFile); - if (cachedAudio == nullptr && reader == nullptr) + const auto* decodedSource = + getFullyDecodedSource(readerKey); + auto* reader = decodedSource == nullptr + ? getCachedReader(readerKey) + : nullptr; + StreamingContinuityState* + streamingContinuity = nullptr; + if (reader != nullptr) + { + auto continuity = + streamingContinuityStates.find( + readerKey); + if (continuity + != streamingContinuityStates.end()) + { + streamingContinuity = + &continuity->second; + } + } + if (decodedSource == nullptr + && reader == nullptr) { const int missingReaders = missingReaderCount.fetch_add (1, std::memory_order_relaxed) + 1; - logAudioPlayback ("fillTrackBuffer missingReader track=" + trackId + OPENSTUDIO_LOG_AUDIO_PLAYBACK("fillTrackBuffer missingStreamingReader track=" + trackId + " clipId=" + clip.clipId + " file=" + playbackFile.getFullPathName() + " currentTime=" + juce::String (currentTime, 3) + " missingReaderCount=" + juce::String (missingReaders)); + juce::ignoreUnused (missingReaders); return false; } - if (cachedAudio == nullptr) - audioDataCacheMissCount.fetch_add(1, std::memory_order_relaxed); - - const double fileSampleRate = cachedAudio ? cachedAudio->sampleRate : reader->sampleRate; + const double fileSampleRate = + decodedSource != nullptr + ? decodedSource->sampleRate + : reader->sampleRate; const double ratio = fileSampleRate / sampleRate; double exactFileStart = juce::jmax (0.0, playbackOffset) * fileSampleRate; const double roundedFileStart = std::round (exactFileStart); @@ -1281,7 +2353,10 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, const double bufferStartPosition = static_cast<double> (readStartOffset) + fileStartFraction; int outputSamples = requestedOutputSamples; int fileSamplesToRead = static_cast<int> (std::ceil (bufferStartPosition + outputSamples * ratio)) + 3; - const juce::int64 sourceLengthInSamples = cachedAudio ? cachedAudio->lengthInSamples : reader->lengthInSamples; + const juce::int64 sourceLengthInSamples = + decodedSource != nullptr + ? decodedSource->lengthInSamples + : reader->lengthInSamples; const juce::int64 fileSamplesAvailable = sourceLengthInSamples - readStartSample; if (fileSamplesAvailable <= 0) return false; @@ -1294,7 +2369,10 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, if (outputSamples <= 0 || fileSamplesToRead <= 0) return false; - const int readerChannels = cachedAudio ? cachedAudio->numChannels : static_cast<int> (reader->numChannels); + const int readerChannels = + decodedSource != nullptr + ? decodedSource->numChannels + : static_cast<int>(reader->numChannels); if (reusableFileBuffer.getNumChannels() < readerChannels || reusableFileBuffer.getNumSamples() < fileSamplesToRead) { @@ -1303,79 +2381,148 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, fileBufferResizeCount.fetch_add (1, std::memory_order_relaxed); } reusableFileBuffer.clear (0, fileSamplesToRead); - if (cachedAudio) + bool allReadAheadReady = true; + if (decodedSource != nullptr) { - const int sourceStart = static_cast<int>(readStartSample); - for (int ch = 0; ch < readerChannels; ++ch) - reusableFileBuffer.copyFrom(ch, 0, cachedAudio->buffer, ch, sourceStart, fileSamplesToRead); + const int decodedStartSample = + static_cast<int>(readStartSample); + for (int channel = 0; + channel < readerChannels; + ++channel) + { + reusableFileBuffer.copyFrom( + channel, + 0, + decodedSource->samples, + channel, + decodedStartSample, + fileSamplesToRead); + } } else { - reader->read (&reusableFileBuffer, 0, fileSamplesToRead, readStartSample, true, true); + allReadAheadReady = reader->read( + &reusableFileBuffer, + 0, + fileSamplesToRead, + readStartSample, + true, + true); + } + const double outputTimelineStart = + currentTime + + static_cast<double>(outputStart) + / sampleRate; + if (streamingContinuity != nullptr) + { + const double continuityTolerance = + 2.0 / sampleRate; + const bool timelineContiguous = + streamingContinuity + ->hasExpectedTimelineTime + && std::abs( + outputTimelineStart + - streamingContinuity + ->expectedNextTimelineTime) + <= continuityTolerance; + const bool startedRecovery = + streamingContinuity->beginBlock( + allReadAheadReady, + timelineContiguous, + readerChannels); + if (startedRecovery) + { + streamingContinuityRecoveryCount + .fetch_add( + 1, + std::memory_order_relaxed); + } + } + if (! allReadAheadReady) + { + audioDataCacheMissCount.fetch_add(1, std::memory_order_relaxed); + streamingContinuityConcealmentCount + .fetch_add( + 1, + std::memory_order_relaxed); + streamingContinuityConcealedSampleCount + .fetch_add( + static_cast<juce::int64>( + outputSamples) + * static_cast<juce::int64>( + juce::jmin( + playbackOutputChannels, + readerChannels)), + std::memory_order_relaxed); } const bool allowLivePitchPreviewForChunk = ! usingRenderedPreviewSegment && ! usingCorrectedSource; const double chunkClipStart = currentTime + (static_cast<double> (outputStart) / sampleRate) - clip.startTime; - if (clip.clipId.isNotEmpty()) + if (allReadAheadReady + && clip.clipId.isNotEmpty()) { auto previewIt = clipPitchPreviews.find (clip.clipId); if (previewIt != clipPitchPreviews.end() && previewIt->second != nullptr) { - juce::ScopedLock clipSl (previewIt->second->clipLock); - auto& preview = *previewIt->second; - const auto& previewData = preview.previewData; - const double blockMidTime = chunkClipStart + (outputSamples * 0.5 / sampleRate); - const bool withinPreviewWindow = blockMidTime >= previewData.previewStartSec - && blockMidTime <= previewData.previewEndSec; - const float pitchRatio = lookupPitchRatio (previewData.pitchSegments, blockMidTime); - const bool pitchPreviewActive = allowLivePitchPreviewForChunk - && withinPreviewWindow - && std::abs (pitchRatio - 1.0f) > 0.001f; - - if (! pitchPreviewActive) + const juce::ScopedTryLock clipSl(previewIt->second->clipLock); + if (clipSl.isLocked()) { - preview.lastPlaybackTime = -1.0; - } - else - { - if (! preview.prepared) + auto& preview = *previewIt->second; + const auto& previewData = preview.previewData; + const double blockMidTime = chunkClipStart + (outputSamples * 0.5 / sampleRate); + const bool withinPreviewWindow = blockMidTime >= previewData.previewStartSec + && blockMidTime <= previewData.previewEndSec; + const float pitchRatio = lookupPitchRatio (previewData.pitchSegments, blockMidTime); + const bool pitchPreviewActive = allowLivePitchPreviewForChunk + && withinPreviewWindow + && std::abs (pitchRatio - 1.0f) > 0.001f; + + if (! pitchPreviewActive) { - preview.stretcher.presetCheaper (readerChannels, static_cast<float> (fileSampleRate)); - preview.prepared = true; + preview.lastPlaybackTime = -1.0; } - if (preview.lastPlaybackTime < 0.0 - || std::abs (chunkClipStart - preview.lastPlaybackTime) > 0.1) + else { - preview.stretcher.presetCheaper (readerChannels, static_cast<float> (fileSampleRate)); + if (! preview.prepared) + { + preview.stretcher.presetCheaper (readerChannels, static_cast<float> (fileSampleRate)); + preview.prepared = true; + } + if (preview.lastPlaybackTime < 0.0 + || std::abs (chunkClipStart - preview.lastPlaybackTime) > 0.1) + { + preview.stretcher.presetCheaper (readerChannels, static_cast<float> (fileSampleRate)); + } + preview.lastPlaybackTime = chunkClipStart + (outputSamples / sampleRate); + + const float tonalityLimitNorm = static_cast<float> ( + fileSampleRate > 0.0 + ? getPitchOnlyPreviewTonalityLimitHz (pitchRatio < 1.0f) / fileSampleRate + : 0.0); + preview.stretcher.setTransposeFactor (pitchRatio, tonalityLimitNorm); + preview.stretcher.setFormantFactor (1.0f, true); + + if (pitchShiftWorkBuffer.getNumSamples() < fileSamplesToRead) + { + pitchShiftWorkBuffer.setSize (readerChannels, fileSamplesToRead); + pitchShiftWorkBufferResizeCount.fetch_add (1, std::memory_order_relaxed); + } + for (int ch = 0; ch < readerChannels; ++ch) + { + pitchPreviewInPtrs[static_cast<size_t> (ch)] = reusableFileBuffer.getReadPointer (ch); + pitchPreviewOutPtrs[static_cast<size_t> (ch)] = pitchShiftWorkBuffer.getWritePointer (ch); + } + preview.stretcher.process (pitchPreviewInPtrs, fileSamplesToRead, pitchPreviewOutPtrs, fileSamplesToRead); + for (int ch = 0; ch < readerChannels; ++ch) + reusableFileBuffer.copyFrom (ch, 0, pitchShiftWorkBuffer, ch, 0, fileSamplesToRead); } - preview.lastPlaybackTime = chunkClipStart + (outputSamples / sampleRate); - - const float tonalityLimitNorm = static_cast<float> ( - fileSampleRate > 0.0 - ? getPitchOnlyPreviewTonalityLimitHz (pitchRatio < 1.0f) / fileSampleRate - : 0.0); - preview.stretcher.setTransposeFactor (pitchRatio, tonalityLimitNorm); - preview.stretcher.setFormantFactor (1.0f, true); - - if (pitchShiftWorkBuffer.getNumSamples() < fileSamplesToRead) - { - pitchShiftWorkBuffer.setSize (readerChannels, fileSamplesToRead); - pitchShiftWorkBufferResizeCount.fetch_add (1, std::memory_order_relaxed); - } - for (int ch = 0; ch < readerChannels; ++ch) - { - pitchPreviewInPtrs[static_cast<size_t> (ch)] = reusableFileBuffer.getReadPointer (ch); - pitchPreviewOutPtrs[static_cast<size_t> (ch)] = pitchShiftWorkBuffer.getWritePointer (ch); - } - preview.stretcher.process (pitchPreviewInPtrs, fileSamplesToRead, pitchPreviewOutPtrs, fileSamplesToRead); - for (int ch = 0; ch < readerChannels; ++ch) - reusableFileBuffer.copyFrom (ch, 0, pitchShiftWorkBuffer, ch, 0, fileSamplesToRead); } } } - const int outChannels = buffer.getNumChannels(); + const int outChannels = + playbackOutputChannels; const int channelsToProcess = std::min (outChannels, readerChannels); for (int i = 0; i < outputSamples; ++i) { @@ -1392,14 +2539,48 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, for (int ch = 0; ch < channelsToProcess; ++ch) { - const float sourceSample = sampleBufferCubic (reusableFileBuffer, ch, fileSamplesToRead, filePos); - buffer.addSample (ch, outputStart + i, sourceSample * totalGain); + const float sourceSample = + allReadAheadReady + ? sampleBufferCubic( + reusableFileBuffer, + ch, + fileSamplesToRead, + filePos) + : 0.0f; + const float continuitySample = + streamingContinuity != nullptr + ? streamingContinuity + ->processSample( + ch, + sourceSample, + allReadAheadReady) + : sourceSample; + const float mixedSample = + continuitySample * totalGain; + playbackMix.addSample( + ch, + outputStart + i, + mixedSample); } + if (streamingContinuity != nullptr) + streamingContinuity->advanceFrame( + allReadAheadReady); + } + if (streamingContinuity != nullptr) + { + streamingContinuity + ->expectedNextTimelineTime = + outputTimelineStart + + static_cast<double>( + outputSamples) + / sampleRate; + streamingContinuity + ->hasExpectedTimelineTime = true; } if (shouldLogDetailed) { - logAudioPlayback ("fillTrackBuffer chunk track=" + trackId + OPENSTUDIO_LOG_AUDIO_PLAYBACK("fillTrackBuffer chunk track=" + trackId + " clipId=" + clip.clipId + " out=[" + juce::String (outputStart) + "," + juce::String (outputStart + outputSamples) + "]" + " clipStart=" + juce::String (chunkClipStart, 4) @@ -1440,12 +2621,14 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, double playbackOffset = clip.offset + chunkClipStart; bool usingRenderedPreviewSegment = false; bool usingCorrectedSource = false; + const juce::String* readerKey = &clip.readerKey; if (activeSegment != nullptr) { playbackFile = activeSegment->audioFile; playbackOffset = activeSegment->fileOffsetSec + (chunkClipStart - activeSegment->startSec); usingRenderedPreviewSegment = true; + readerKey = &activeSegment->readerKey; } else if (clip.clipId.isNotEmpty()) { @@ -1455,316 +2638,64 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, && playbackFile == correctedIt->second; } - mixedAnyChunk = mixChunk (chunkStart, chunkEnd - chunkStart, playbackFile, playbackOffset, + mixedAnyChunk = mixChunk (chunkStart, chunkEnd - chunkStart, playbackFile, + *readerKey, playbackOffset, usingRenderedPreviewSegment, usingCorrectedSource) || mixedAnyChunk; } if (mixedAnyChunk) ++mixedClipCount; } - continue; - - // Calculate read position within clip - double offsetInClip = currentTime - clip.startTime; - if (offsetInClip < 0) - { - // Clip starts partway through this buffer - offsetInClip = 0; - } - - // Get cached reader — never does disk I/O (readers pre-loaded in addClip) - juce::File playbackFile = clip.audioFile; - double playbackOffset = clip.offset + offsetInClip; - bool usingRenderedPreviewSegment = false; - if (clip.clipId.isNotEmpty()) - { - auto segmentIt = renderedPreviewSegments.find(clip.clipId); - if (segmentIt != renderedPreviewSegments.end()) - { - const double blockDurationSec = numSamples / sampleRate; - for (const auto& segment : segmentIt->second) - { - if (offsetInClip >= segment.startSec - 0.0005 - && offsetInClip < segment.endSec + 0.0005 - && (offsetInClip + blockDurationSec) > segment.startSec) - { - playbackFile = segment.audioFile; - usingRenderedPreviewSegment = true; - // Segment override files can either be local-zero window renders - // or full-clip renders carrying only a covered region. fileOffsetSec - // maps the clip-relative playhead back into the override file. - playbackOffset = juce::jmax(0.0, offsetInClip - segment.startSec + segment.fileOffsetSec); - break; - } - } - } - } - bool usingCorrectedSource = false; - if (!usingRenderedPreviewSegment && clip.clipId.isNotEmpty()) - { - auto correctedIt = pitchCorrectedFiles.find(clip.clipId); - if (correctedIt != pitchCorrectedFiles.end() - && correctedIt->second.existsAsFile() - && playbackFile == correctedIt->second) - { - usingCorrectedSource = true; - } - } - const bool allowLivePitchPreviewForBlock = !usingRenderedPreviewSegment && !usingCorrectedSource; - if (shouldLogDetailed) - { - logAudioPlayback("fillTrackBuffer overlap track=" + trackId - + " clipId=" + clip.clipId - + " window=[" + juce::String(currentTime, 3) + "," + juce::String(windowEnd, 3) + "]" - + " playbackFile=" + playbackFile.getFullPathName() - + " playbackOffset=" + juce::String(playbackOffset, 3) - + " sourceType=" + juce::String(usingRenderedPreviewSegment ? "preview_segment" - : (usingCorrectedSource ? "corrected" : "original")) - + " livePitchAllowed=" + juce::String(allowLivePitchPreviewForBlock ? "yes" : "no")); - } - - auto* reader = getCachedReader(playbackFile); - if (reader == nullptr) - { - const int missingReaders = missingReaderCount.fetch_add(1, std::memory_order_relaxed) + 1; - logAudioPlayback("fillTrackBuffer missingReader track=" + trackId - + " clipId=" + clip.clipId - + " file=" + playbackFile.getFullPathName() - + " currentTime=" + juce::String(currentTime, 3) - + " missingReaderCount=" + juce::String(missingReaders)); - continue; - } - - // Sample rate conversion ratio (file rate vs device rate) - double fileSampleRate = reader->sampleRate; - double ratio = fileSampleRate / sampleRate; // e.g. 48000/44100 = 1.0884 - - // Calculate file sample position using the FILE's sample rate - juce::int64 fileStartSample = (juce::int64)(playbackOffset * fileSampleRate); - - // How many output samples we can produce for this clip - int outputSamples = numSamples; - - // How many file samples we need to read (+2 for linear interpolation safety) - int fileSamplesToRead = (int)(outputSamples * ratio) + 2; - - // Adjust if we're near the end of the file - juce::int64 fileSamplesAvailable = reader->lengthInSamples - fileStartSample; - if (fileSamplesAvailable <= 0) - continue; - if (fileSamplesAvailable < fileSamplesToRead) - { - fileSamplesToRead = (int)fileSamplesAvailable; - // Reduce output samples accordingly - outputSamples = (int)((fileSamplesToRead - 1) / ratio); - } - - if (outputSamples <= 0 || fileSamplesToRead <= 0) - continue; - ++mixedClipCount; - if (shouldLogDetailed) - { - logAudioPlayback("fillTrackBuffer readReady track=" + trackId - + " clipId=" + clip.clipId - + " fileStartSample=" + juce::String(fileStartSample) - + " outputSamples=" + juce::String(outputSamples) - + " fileSamplesToRead=" + juce::String(fileSamplesToRead)); - } - - // Use pre-allocated buffer (resize only if needed — rare fallback) - int readerChannels = static_cast<int>(reader->numChannels); - if (reusableFileBuffer.getNumChannels() < readerChannels || - reusableFileBuffer.getNumSamples() < fileSamplesToRead) - { - reusableFileBuffer.setSize(juce::jmax(readerChannels, reusableFileBuffer.getNumChannels()), - juce::jmax(fileSamplesToRead, reusableFileBuffer.getNumSamples())); - } - reusableFileBuffer.clear(0, fileSamplesToRead); - - reader->read(&reusableFileBuffer, 0, fileSamplesToRead, fileStartSample, true, true); - - // ---- Real-time pitch preview: apply PitchShifter if active ---- - if (clip.clipId.isNotEmpty()) - { - auto previewIt = clipPitchPreviews.find (clip.clipId); - if (previewIt != clipPitchPreviews.end() && previewIt->second != nullptr) - { - juce::ScopedLock clipSl (previewIt->second->clipLock); - auto& preview = *previewIt->second; - const auto& previewData = preview.previewData; - const double clipTime = offsetInClip; - const double blockMidTime = offsetInClip + (fileSamplesToRead * 0.5 / fileSampleRate); - const bool withinPreviewWindow = blockMidTime >= previewData.previewStartSec - && blockMidTime <= previewData.previewEndSec; - const float pitchRatio = lookupPitchRatio (previewData.pitchSegments, blockMidTime); - const bool pitchPreviewActive = allowLivePitchPreviewForBlock - && withinPreviewWindow - && std::abs (pitchRatio - 1.0f) > 0.001f; - - if (! pitchPreviewActive) - { - preview.lastPlaybackTime = -1.0; - } - else - { - // Prepare stretcher on first use (or after reset) - if (! preview.prepared) - { - preview.stretcher.presetCheaper (readerChannels, static_cast<float> (fileSampleRate)); - preview.prepared = true; - } - - // Re-entering the preview window after a gap (lastPlaybackTime == -1): - // Reset the stretcher so its internal phases align with the current - // audio position. Without this, stale phases from a prior streaming - // position cause phase artifacts that sound "faster" or mis-timed at - // note boundaries. The ~30ms of latency fill that follows the reset is - // brief and far less jarring than the phase-misalignment artifact. - if (preview.lastPlaybackTime < 0.0) - { - preview.stretcher.presetCheaper (readerChannels, static_cast<float> (fileSampleRate)); - } - // Detect seeking: if playback time jumped, reinitialize - else if (std::abs (clipTime - preview.lastPlaybackTime) > 0.1) - { - preview.stretcher.presetCheaper (readerChannels, static_cast<float> (fileSampleRate)); - } - preview.lastPlaybackTime = clipTime + (fileSamplesToRead / fileSampleRate); - - const float tonalityLimitNorm = static_cast<float> ( - fileSampleRate > 0.0 - ? getPitchOnlyPreviewTonalityLimitHz (pitchRatio < 1.0f) / fileSampleRate - : 0.0); - preview.stretcher.setTransposeFactor (pitchRatio, tonalityLimitNorm); - // Keep the legacy live fallback in the same timbre family as the - // note-local renderer: preserve formants directly instead of using - // pitch-ratio compensation, which brightens upward edits and darkens - // downward edits by construction. - preview.stretcher.setFormantFactor (1.0f, true); - - // Ensure pitch shift work buffer is large enough - if (pitchShiftWorkBuffer.getNumSamples() < fileSamplesToRead) - pitchShiftWorkBuffer.setSize (readerChannels, fileSamplesToRead); - - // Use pre-allocated pointer vectors — avoids heap alloc per clip per callback. - // readerChannels is always 1 or 2; pitchPreviewInPtrs/OutPtrs are sized to 2. - for (int ch = 0; ch < readerChannels; ++ch) - { - pitchPreviewInPtrs[static_cast<size_t> (ch)] = reusableFileBuffer.getReadPointer (ch); - pitchPreviewOutPtrs[static_cast<size_t> (ch)] = pitchShiftWorkBuffer.getWritePointer (ch); - } - - preview.stretcher.process (pitchPreviewInPtrs, fileSamplesToRead, pitchPreviewOutPtrs, fileSamplesToRead); - - for (int ch = 0; ch < readerChannels; ++ch) - reusableFileBuffer.copyFrom (ch, 0, pitchShiftWorkBuffer, ch, 0, fileSamplesToRead); - } - } - } - - // Apply per-clip gain (convert dB to linear) - float clipGain = juce::Decibels::decibelsToGain(static_cast<float>(clip.volumeDB)); - int fileChannels = readerChannels; - int outChannels = buffer.getNumChannels(); - - // Look up gain envelope for this clip - const std::vector<GainEnvelopePoint>* envPoints = nullptr; - if (clip.clipId.isNotEmpty()) - { - auto envIt = gainEnvelopes.find(clip.envelopeKey); - if (envIt != gainEnvelopes.end() && !envIt->second.empty()) - envPoints = &envIt->second; - } - - // Render mode with sample rate conversion: use Lagrange interpolation - if (renderMode && ratio != 1.0) - { - // Use a temporary buffer for Lagrange-resampled output per channel - // Process each channel independently - int channelsToProcess = std::min(outChannels, fileChannels); - for (int ch = 0; ch < channelsToProcess; ++ch) - { - auto& interpolator = (ch == 0) ? lagrangeInterpolatorL : lagrangeInterpolatorR; - const float* inputData = reusableFileBuffer.getReadPointer(ch); - - // Create a temporary output buffer for this channel - // We write directly into the output by accumulating sample by sample - // Use a small stack buffer for the resampled data - std::vector<float> resampledData(static_cast<size_t>(outputSamples)); - interpolator.process(ratio, inputData, resampledData.data(), outputSamples); - - // Apply fades, gain envelope, and clip gain, then mix into output buffer - for (int i = 0; i < outputSamples; ++i) - { - float fadeGain = 1.0f; - double sampleTimeInClip = offsetInClip + (i / sampleRate); - - if (clip.fadeIn > 0.0 && sampleTimeInClip < clip.fadeIn) - { - float t = static_cast<float>(sampleTimeInClip / clip.fadeIn); - fadeGain *= applyFadeCurve(t, clip.fadeInCurve); - } - - double timeFromEnd = clip.duration - sampleTimeInClip; - if (clip.fadeOut > 0.0 && timeFromEnd < clip.fadeOut) - { - float t = static_cast<float>(timeFromEnd / clip.fadeOut); - fadeGain *= applyFadeCurve(t, clip.fadeOutCurve); - } - - float envGain = envPoints ? interpolateGainEnvelope(*envPoints, sampleTimeInClip) : 1.0f; + } - buffer.addSample(ch, i, resampledData[static_cast<size_t>(i)] * clipGain * fadeGain * envGain); - } - } - } - else - { - // Real-time path: Resample (linear interpolation) + apply gain/fades in one pass - for (int i = 0; i < outputSamples; ++i) + const bool startedOuterRecovery = + trackContinuity.beginBlock( + true, + timelineContiguous, + playbackOutputChannels); + if (startedOuterRecovery) + { + outerLockContinuityRecoveryCount.fetch_add( + 1, std::memory_order_relaxed); + } + for (int sample = 0; + sample < numSamples; + ++sample) + { + for (int channel = 0; + channel < playbackOutputChannels; + ++channel) { - // Fractional position in the file buffer for this output sample - double filePos = i * ratio; - int idx = (int)filePos; - float frac = (float)(filePos - idx); - - // Fade calculation (with curve support) - float fadeGain = 1.0f; - double sampleTimeInClip = offsetInClip + (i / sampleRate); - - if (clip.fadeIn > 0.0 && sampleTimeInClip < clip.fadeIn) - { - float t = static_cast<float>(sampleTimeInClip / clip.fadeIn); - fadeGain *= applyFadeCurve(t, clip.fadeInCurve); - } - - double timeFromEnd = clip.duration - sampleTimeInClip; - if (clip.fadeOut > 0.0 && timeFromEnd < clip.fadeOut) - { - float t = static_cast<float>(timeFromEnd / clip.fadeOut); - fadeGain *= applyFadeCurve(t, clip.fadeOutCurve); - } - - float envGain = envPoints ? interpolateGainEnvelope(*envPoints, sampleTimeInClip) : 1.0f; - float totalGain = clipGain * fadeGain * envGain; - - // Linear interpolation + gain for each channel - for (int ch = 0; ch < std::min(outChannels, fileChannels); ++ch) - { - float s0 = reusableFileBuffer.getSample(ch, idx); - float s1 = (idx + 1 < fileSamplesToRead) ? reusableFileBuffer.getSample(ch, idx + 1) : s0; - float sample = s0 + frac * (s1 - s0); // lerp - buffer.addSample(ch, i, sample * totalGain); - } + const float continuousSample = + trackContinuity.processSample( + channel, + playbackMix.getSample( + channel, sample), + true); + playbackMix.setSample( + channel, + sample, + continuousSample); + buffer.addSample( + channel, + sample, + continuousSample); } - } // end real-time path + trackContinuity.advanceFrame(true); } + trackContinuity.expectedNextTimelineTime = + currentTime + + static_cast<double>(numSamples) + / sampleRate; + trackContinuity.hasExpectedTimelineTime = + true; const bool shouldUpdatePlaybackPeak = mixedClipCount > 0 && ((fillCall & 15) == 0 || shouldLogDetailed); const float playbackPeak = mixedClipCount == 0 ? 0.0f : (shouldUpdatePlaybackPeak - ? peakForBuffer(buffer, numSamples) + ? peakForBuffer( + playbackMix, numSamples) : lastTrackPlaybackPeak.load(std::memory_order_relaxed)); lastOverlappingClipCount.store(overlappingClipCount, std::memory_order_relaxed); lastMixedClipCount.store(mixedClipCount, std::memory_order_relaxed); @@ -1772,7 +2703,7 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, lastTrackPlaybackPeak.store(playbackPeak, std::memory_order_relaxed); if (shouldLogDetailed || (overlappingClipCount > 0 && mixedClipCount == 0)) { - logAudioPlayback("fillTrackBuffer summary track=" + trackId + OPENSTUDIO_LOG_AUDIO_PLAYBACK("fillTrackBuffer summary track=" + trackId + " overlapping=" + juce::String(overlappingClipCount) + " mixed=" + juce::String(mixedClipCount) + " peak=" + juce::String(playbackPeak, 4) @@ -1783,3 +2714,4 @@ void PlaybackEngine::fillTrackBuffer(const juce::String& trackId, #if defined(_MSC_VER) #pragma warning(pop) #endif +#undef OPENSTUDIO_LOG_AUDIO_PLAYBACK diff --git a/Source/PlaybackEngine.h b/Source/PlaybackEngine.h index 1b163c7..1a3da26 100644 --- a/Source/PlaybackEngine.h +++ b/Source/PlaybackEngine.h @@ -15,6 +15,7 @@ #include <vector> #include <map> #include <limits> +#include <array> /** * PlaybackEngine manages audio clip playback for the DAW. @@ -51,6 +52,7 @@ class PlaybackEngine juce::String trackId; // Which track this clip belongs to juce::String clipId; // Unique clip ID for envelope lookup juce::String envelopeKey; // Pre-computed "trackId::clipId" key — avoids string alloc in audio thread + juce::String readerKey; // Stable read-ahead stream for this logical clip bool isActive; // Whether clip is currently loaded ClipInfo(const juce::File& file, double start, double dur, const juce::String& track, double off = 0.0, @@ -66,12 +68,14 @@ class PlaybackEngine const juce::String& clipId = juce::String(), const juce::File& sourceAudioFile = juce::File(), double sourceOffset = -1.0); void removeClip(const juce::String& trackId, const juce::String& filePath); + void removeClipById(const juce::String& trackId, const juce::String& clipId); void clearAllClips(); void clearTrackClips(const juce::String& trackId); // Hot-swap a clip's audio file (used after pitch correction writes a new file) void replaceClipAudioFile(const juce::String& clipId, const juce::File& newFile); void queueDeferredClipAudioFile(const juce::String& clipId, const juce::File& newFile, bool restoringOriginal = false); + void cancelDeferredClipAudioFile(const juce::String& clipId); bool commitDeferredClipAudioFile(const juce::String& clipId); int commitAllDeferredClipAudioFiles(); @@ -84,6 +88,7 @@ class PlaybackEngine double startSec = 0.0; double endSec = 0.0; double fileOffsetSec = 0.0; + juce::String readerKey; }; struct ClipPlaybackSourceStatus @@ -202,6 +207,14 @@ class PlaybackEngine juce::String monitorMode; }; + struct PitchPreviewRoutingDiagnosticStatus + { + bool scrubPreviewActive = false; + bool clipLivePreviewActive = false; + bool renderedSegmentActive = false; + bool correctedSourceActive = false; + }; + // Set a pitch correction map for a clip (enables real-time preview) void setClipPitchPreview (const juce::String& clipId, const ClipPitchPreviewData& preview); @@ -218,9 +231,16 @@ class PlaybackEngine bool updatePitchScrubPreview (const juce::String& clipId, float pitchRatio); void clearPitchScrubPreview (const juce::String& clipId); bool hasPitchScrubPreview (const juce::String& clipId) const; + bool mayRenderPitchScrubPreview() const noexcept + { + return pitchScrubPreviewMayRender.load(std::memory_order_acquire); + } void renderPitchScrubPreview (juce::AudioBuffer<float>& buffer, double sampleRate); PitchScrubPreviewStatus getPitchScrubPreviewStatus (const juce::String& clipId = {}) const; PitchPreviewRoutingStatus getPitchPreviewRoutingStatus (const juce::String& clipId = {}) const; + // Lock-free aggregate route snapshot for diagnostics. Per-clip queries use + // getPitchPreviewRoutingStatus(clipId) and retain their detailed locked path. + PitchPreviewRoutingDiagnosticStatus getPitchPreviewRoutingDiagnosticStatus() const noexcept; // Utility int getNumClips() const { return (int)clips.size(); } @@ -235,7 +255,57 @@ class PlaybackEngine int getRenderResampleScratchResizeCount() const { return renderResampleScratchResizeCount.load(std::memory_order_relaxed); } int getChunkBoundaryReserveCount() const { return chunkBoundaryReserveCount.load(std::memory_order_relaxed); } int getAudioDataCacheMissCount() const { return audioDataCacheMissCount.load(std::memory_order_relaxed); } - int getAudioDataCachedFileCount() const { const juce::ScopedLock sl(lock); return static_cast<int>(audioDataCache.size()); } + int getAudioDataCachedFileCount() const { return cachedReaderCount.load(std::memory_order_acquire); } + juce::int64 getStreamingReadAheadCapacityBytes() const; + int getStreamingReaderEvictionCount() const { return streamingReaderEvictionCount.load(std::memory_order_relaxed); } + int getStreamingReaderBudgetOvercommitCount() const { return streamingReaderBudgetOvercommitCount.load(std::memory_order_relaxed); } + int getFullyDecodedSourceCount() const { return fullyDecodedSourceCount.load(std::memory_order_acquire); } + juce::int64 getFullyDecodedSourceBytes() const { return fullyDecodedSourceBytes.load(std::memory_order_acquire); } + int getFullyDecodedSourceEvictionCount() const { return fullyDecodedSourceEvictionCount.load(std::memory_order_relaxed); } + int getFullyDecodedSourceBudgetFallbackCount() const { return fullyDecodedSourceBudgetFallbackCount.load(std::memory_order_relaxed); } + int getStreamingContinuityConcealmentCount() const { return streamingContinuityConcealmentCount.load(std::memory_order_relaxed); } + juce::int64 getStreamingContinuityConcealedSampleCount() const { return streamingContinuityConcealedSampleCount.load(std::memory_order_relaxed); } + int getStreamingContinuityRecoveryCount() const { return streamingContinuityRecoveryCount.load(std::memory_order_relaxed); } + int getOuterLockContinuityConcealmentCount() const { return outerLockContinuityConcealmentCount.load(std::memory_order_relaxed); } + juce::int64 getOuterLockContinuityConcealedSampleCount() const { return outerLockContinuityConcealedSampleCount.load(std::memory_order_relaxed); } + int getOuterLockContinuityRecoveryCount() const { return outerLockContinuityRecoveryCount.load(std::memory_order_relaxed); } + + struct StreamingContinuityRegressionResult + { + bool passed = false; + float concealmentEntryStep = 0.0f; + float recoveryMaximumStep = 0.0f; + float partitionMaximumDifference = 0.0f; + float recoveredSample = 0.0f; + float fadeToZeroFinalSample = 0.0f; + }; + + // Deterministic coverage of the same bounded conceal/recovery state used by + // the callback when a large streaming source misses its read-ahead window. + static StreamingContinuityRegressionResult + runStreamingContinuityRegression() noexcept; + + struct OuterLockContinuityRegressionResult + { + bool passed = false; + int tryLockMisses = 0; + int concealmentEvents = 0; + int recoveryEvents = 0; + float concealmentEntryStep = 0.0f; + float recoveryEntryStep = 0.0f; + float recoveredSample = 0.0f; + }; + + // Forces the publication lock to be owned by another thread and verifies + // that fillTrackBuffer emits bounded continuity rather than dropping the + // complete playback contribution. + static OuterLockContinuityRegressionResult + runOuterLockContinuityRegression(); + + // Control-thread hint used before starts/seeks. It only requests/optionally + // waits for JUCE's background buffering thread; source decoding never runs + // on the realtime callback. + void requestReadAheadAtTime(double timelineTimeSeconds); // Thread-safe snapshot of all clips (for offline rendering) std::vector<ClipInfo> getClipSnapshot() const; @@ -243,20 +313,76 @@ class PlaybackEngine // Render mode: uses Lagrange interpolation for higher quality resampling void setRenderMode(bool isRendering) { renderMode = isRendering; } - // Max cached readers before eviction + // Each streaming reader holds two 32768-frame blocks and source readers are + // capped to two channels. The normal cache target is therefore 128 MiB. + // Readers referenced by active clips are never evicted into permanent + // silence; an exceptional overcommit is surfaced by telemetry. static constexpr int MAX_CACHED_READERS = 256; + static constexpr int STREAMING_READ_AHEAD_SAMPLES = 32768; + static constexpr juce::int64 MAX_FULLY_DECODED_SOURCE_BYTES = + 64LL * 1024LL * 1024LL; + static constexpr juce::int64 MAX_FULLY_DECODED_CACHE_BYTES = + 256LL * 1024LL * 1024LL; private: - std::vector<ClipInfo> clips; - std::map<juce::String, std::unique_ptr<juce::AudioFormatReader>> readers; - struct CachedAudioData + static constexpr int STREAMING_RECOVERY_SAMPLES = 64; + static constexpr float STREAMING_CONCEALMENT_DECAY = 0.9995f; + + struct FullyDecodedSource { - juce::AudioBuffer<float> buffer; + juce::AudioBuffer<float> samples; double sampleRate = 0.0; juce::int64 lengthInSamples = 0; int numChannels = 0; + juce::int64 decodedBytes = 0; + }; + + struct StreamingContinuityState + { + std::array<float, 2> lastOutput {}; + std::array<float, 2> concealedOutput {}; + double expectedNextTimelineTime = 0.0; + int recoverySamplesRemaining = 0; + int activeChannels = 0; + bool hasOutputHistory = false; + bool hasExpectedTimelineTime = false; + bool concealing = false; + + void reset(int channels) noexcept; + bool beginBlock(bool sourceReady, + bool timelineContiguous, + int channels) noexcept; + float processSample(int channel, + float sourceSample, + bool sourceReady) noexcept; + void advanceFrame(bool sourceReady) noexcept; }; - std::map<juce::String, std::shared_ptr<CachedAudioData>> audioDataCache; + + struct TrackPlaybackContinuitySlot + { + juce::int64 trackKey = 0; + juce::uint64 lastUseCounter = 0; + StreamingContinuityState continuity; + }; + + std::vector<ClipInfo> clips; + // Declared before readers so it outlives them during reverse-order member + // destruction. BufferingAudioReader registers itself as a time-slice client. + juce::TimeSliceThread streamingReadAheadThread { "PlaybackEngine-ReadAhead" }; + std::map<juce::String, std::shared_ptr<juce::BufferingAudioReader>> readers; + // One fixed-size state per streaming reader. Entries are created and + // retired on the control thread while the existing publication lock is + // held; the callback only performs a lookup and updates scalar state. + std::map<juce::String, StreamingContinuityState> + streamingContinuityStates; + // Eligible sources are decoded completely before publication. The callback + // reads them through raw const pointers and never changes ownership or + // waits on a decoder/read-ahead lock. + std::map<juce::String, std::unique_ptr<FullyDecodedSource>> + fullyDecodedSources; + std::map<juce::String, juce::int64> + fullyDecodedSourceAccessTimes; + juce::int64 fullyDecodedBytesInUse = 0; juce::AudioFormatManager formatManager; mutable juce::CriticalSection lock; @@ -268,18 +394,34 @@ class PlaybackEngine // Pre-allocated file read buffer (avoids heap alloc on audio thread) juce::AudioBuffer<float> reusableFileBuffer; + // Playback is accumulated separately from sends/live input so an outer + // publication-lock miss can conceal only the missing clip contribution. + juce::AudioBuffer<float> reusableTrackPlaybackBuffer; juce::AudioBuffer<float> renderResampleScratch; std::vector<int> reusableChunkBoundaries; + static constexpr size_t TRACK_PLAYBACK_CONTINUITY_SLOT_COUNT = 128; + std::array<TrackPlaybackContinuitySlot, + TRACK_PLAYBACK_CONTINUITY_SLOT_COUNT> + trackPlaybackContinuitySlots {}; + juce::uint64 trackPlaybackContinuityUseCounter = 0; + StreamingContinuityState& + getTrackPlaybackContinuityState( + const juce::String& trackId) noexcept; + // Get cached audio format reader (audio-thread safe — never creates readers) - juce::AudioFormatReader* getCachedReader(const juce::File& file); + juce::BufferingAudioReader* getCachedReader(const juce::String& readerKey); + const FullyDecodedSource* getFullyDecodedSource( + const juce::String& readerKey) const noexcept; // Pre-load reader on message thread so it's ready for audio thread - void preloadReader(const juce::File& file); - void preloadAudioData(const juce::File& file, juce::AudioFormatReader& reader); - - // Legacy: get or create reader (only called from message thread now) - juce::AudioFormatReader* getReader(const juce::File& file); + void preloadReader(const juce::File& file, + const juce::String& readerKey, + double initialOffsetSeconds = 0.0, + int maxWaitMilliseconds = 50); + static bool primeStreamingReader(juce::BufferingAudioReader& reader, + double offsetSeconds, + int maxWaitMilliseconds); // Apply a fade curve to a normalized t value (0.0 to 1.0) // curveType: 0=linear, 1=equal_power, 2=s_curve, 3=log, 4=exp @@ -294,7 +436,14 @@ class PlaybackEngine std::map<juce::String, juce::int64> readerAccessTimes; // Evict oldest readers when cache exceeds limit - void evictOldReaders(); + void evictOldReaders(const juce::String& protectedKey = {}); + bool evictFullyDecodedSourcesToFitLocked( + juce::int64 requiredBytes, + const juce::String& protectedKey, + std::vector<std::unique_ptr<FullyDecodedSource>>& retiredSources); + void refreshStreamingReaderDiagnosticsLocked() noexcept; + void refreshFullyDecodedSourceDiagnosticsLocked() noexcept; + void refreshPitchPreviewRoutingDiagnosticsLocked() noexcept; // ---- Real-time pitch preview state ---- @@ -309,6 +458,7 @@ class PlaybackEngine // Keyed by clipId — only clips with active pitch preview have entries std::map<juce::String, std::unique_ptr<ClipPitchPreviewState>> clipPitchPreviews; + std::atomic<bool> pitchScrubPreviewMayRender { false }; PitchScrubPreviewData pitchScrubPreview; PitchScrubPreviewStatus pitchScrubPreviewStatus; signalsmith::stretch::SignalsmithStretch<float> pitchScrubStretcher; @@ -352,6 +502,25 @@ class PlaybackEngine std::atomic<int> renderResampleScratchResizeCount { 0 }; std::atomic<int> chunkBoundaryReserveCount { 0 }; std::atomic<int> audioDataCacheMissCount { 0 }; + std::atomic<int> cachedReaderCount { 0 }; + std::atomic<juce::int64> cachedStreamingReadAheadCapacityBytes { 0 }; + std::atomic<unsigned int> pitchPreviewRoutingDiagnosticFlags { 0 }; + std::atomic<int> streamingReaderEvictionCount { 0 }; + std::atomic<int> streamingReaderBudgetOvercommitCount { 0 }; + std::atomic<int> fullyDecodedSourceCount { 0 }; + std::atomic<juce::int64> fullyDecodedSourceBytes { 0 }; + std::atomic<int> fullyDecodedSourceEvictionCount { 0 }; + std::atomic<int> fullyDecodedSourceBudgetFallbackCount { 0 }; + std::atomic<int> streamingContinuityConcealmentCount { 0 }; + std::atomic<juce::int64> streamingContinuityConcealedSampleCount { 0 }; + std::atomic<int> streamingContinuityRecoveryCount { 0 }; + std::atomic<int> outerLockContinuityConcealmentCount { 0 }; + std::atomic<juce::int64> outerLockContinuityConcealedSampleCount { 0 }; + std::atomic<int> outerLockContinuityRecoveryCount { 0 }; + + static_assert(std::atomic<int>::is_always_lock_free); + static_assert(std::atomic<juce::int64>::is_always_lock_free); + static_assert(std::atomic<unsigned int>::is_always_lock_free); JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PlaybackEngine) }; diff --git a/Source/PluginManager.cpp b/Source/PluginManager.cpp index 06e66f3..7a79a4f 100644 --- a/Source/PluginManager.cpp +++ b/Source/PluginManager.cpp @@ -15,15 +15,599 @@ juce::File getLegacyStudio13DocumentsDirectory() auto documentsDir = juce::File::getSpecialLocation(juce::File::userDocumentsDirectory); return documentsDir.getChildFile("Studio13"); } + +constexpr bool shouldIgnorePathCase() +{ + #if JUCE_WINDOWS + return true; + #else + return false; + #endif +} + +juce::String normalisePath(const juce::String& path) +{ + auto cleanedPath = path.trim().unquoted(); + if (cleanedPath.isEmpty()) + return {}; + + if (cleanedPath == "~" || cleanedPath.startsWith("~/") || cleanedPath.startsWith("~\\")) + { + cleanedPath = juce::File::getSpecialLocation(juce::File::userHomeDirectory).getFullPathName() + + cleanedPath.substring(1); + } + + #if JUCE_WINDOWS + int variableStart = cleanedPath.indexOfChar('%'); + while (variableStart >= 0) + { + const int variableEnd = cleanedPath.indexOfChar(variableStart + 1, '%'); + if (variableEnd < 0) + break; + + const auto variableName = cleanedPath.substring(variableStart + 1, variableEnd); + const auto variableValue = juce::SystemStats::getEnvironmentVariable(variableName, {}); + if (variableValue.isEmpty()) + return {}; + + cleanedPath = cleanedPath.replaceSection(variableStart, + variableEnd - variableStart + 1, + variableValue); + variableStart = cleanedPath.indexOfChar(variableStart + variableValue.length(), '%'); + } + #endif + + auto file = juce::File(cleanedPath); + if (file.isSymbolicLink()) + file = file.getLinkedTarget(); + + return file.getFullPathName(); +} + +juce::String normaliseCandidateIdentifier(const juce::String& identifier) +{ + auto cleanedIdentifier = identifier.trim().unquoted(); + if (cleanedIdentifier.isEmpty()) + return {}; + + if (juce::File::isAbsolutePath(cleanedIdentifier)) + return juce::File(cleanedIdentifier).getFullPathName(); + + return cleanedIdentifier; +} + +bool containsNormalisedPath(const juce::StringArray& values, const juce::String& value) +{ + return values.contains(value, shouldIgnorePathCase()); +} + +bool candidateIdentifiersEqual(const juce::String& first, const juce::String& second) +{ + const auto normalisedFirst = normaliseCandidateIdentifier(first); + const auto normalisedSecond = normaliseCandidateIdentifier(second); + const bool bothAreFilePaths = juce::File::isAbsolutePath(normalisedFirst) + && juce::File::isAbsolutePath(normalisedSecond); + + if (bothAreFilePaths && shouldIgnorePathCase()) + return normalisedFirst.equalsIgnoreCase(normalisedSecond); + + // Non-filesystem identifiers such as LV2 URIs are case-sensitive on every + // platform, including Windows. + return normalisedFirst == normalisedSecond; +} + +int indexOfCandidateIdentifier(const juce::StringArray& values, const juce::String& value) +{ + for (int i = 0; i < values.size(); ++i) + if (candidateIdentifiersEqual(values[i], value)) + return i; + return -1; +} + +bool containsCandidateIdentifier(const juce::StringArray& values, const juce::String& value) +{ + return indexOfCandidateIdentifier(values, value) >= 0; +} + +void addUniqueCandidateIdentifier(juce::StringArray& values, const juce::String& value) +{ + if (!containsCandidateIdentifier(values, value)) + values.add(value); +} + +void addUniquePath(juce::StringArray& paths, const juce::String& path) +{ + auto normalised = normalisePath(path); + if (normalised.isNotEmpty()) + paths.addIfNotAlreadyThere(normalised, shouldIgnorePathCase()); +} + +void addUniquePath(juce::StringArray& paths, const juce::File& path) +{ + addUniquePath(paths, path.getFullPathName()); +} + +void addSearchPath(juce::StringArray& paths, const juce::FileSearchPath& searchPath) +{ + for (int i = 0; i < searchPath.getNumPaths(); ++i) + addUniquePath(paths, searchPath.getRawString(i)); +} + +void addEnvironmentSearchPath(juce::StringArray& paths, const juce::String& variableName) +{ + auto value = juce::SystemStats::getEnvironmentVariable(variableName, {}); + if (value.isEmpty()) + return; + + juce::StringArray entries; + #if JUCE_WINDOWS + entries.addTokens(value, ";", "\""); + #else + entries.addTokens(value, ":", "\""); + #endif + entries.trim(); + entries.removeEmptyStrings(); + + for (const auto& entry : entries) + addUniquePath(paths, entry); +} + +juce::StringArray getSearchPathsForFormat(juce::AudioPluginFormat& format, + const juce::StringArray& customPaths) +{ + juce::StringArray paths; + addSearchPath(paths, format.getDefaultLocationsToSearch()); + + const auto formatName = format.getName(); + const auto executableDirectory = juce::File::getSpecialLocation(juce::File::currentExecutableFile) + .getParentDirectory(); + + #if JUCE_WINDOWS + const auto programFiles = juce::File::getSpecialLocation(juce::File::globalApplicationsDirectory); + const auto localAppData = juce::File::getSpecialLocation(juce::File::windowsLocalAppData); + const auto roamingAppData = juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory); + + if (formatName.containsIgnoreCase("VST3")) + { + addUniquePath(paths, programFiles.getChildFile("Common Files").getChildFile("VST3")); + addUniquePath(paths, localAppData.getChildFile("Programs").getChildFile("Common").getChildFile("VST3")); + addUniquePath(paths, executableDirectory.getChildFile("VST3")); + addEnvironmentSearchPath(paths, "VST3_PATH"); + } + else if (formatName.containsIgnoreCase("LV2")) + { + addUniquePath(paths, programFiles.getChildFile("Common Files").getChildFile("LV2")); + addUniquePath(paths, roamingAppData.getChildFile("LV2")); + addUniquePath(paths, juce::File::getSpecialLocation(juce::File::userHomeDirectory).getChildFile(".lv2")); + addEnvironmentSearchPath(paths, "LV2_PATH"); + } + else if (formatName.equalsIgnoreCase("CLAP")) + { + addUniquePath(paths, programFiles.getChildFile("Common Files").getChildFile("CLAP")); + addUniquePath(paths, localAppData.getChildFile("Programs").getChildFile("Common").getChildFile("CLAP")); + addEnvironmentSearchPath(paths, "CLAP_PATH"); + } + #elif JUCE_MAC + const auto userHome = juce::File::getSpecialLocation(juce::File::userHomeDirectory); + + if (formatName.containsIgnoreCase("VST3")) + { + addUniquePath(paths, juce::File("/Library/Audio/Plug-Ins/VST3")); + addUniquePath(paths, userHome.getChildFile("Library/Audio/Plug-Ins/VST3")); + addUniquePath(paths, executableDirectory.getChildFile("VST3")); + addEnvironmentSearchPath(paths, "VST3_PATH"); + } + else if (formatName.containsIgnoreCase("LV2")) + { + addUniquePath(paths, juce::File("/Library/Audio/Plug-Ins/LV2")); + addUniquePath(paths, userHome.getChildFile("Library/Audio/Plug-Ins/LV2")); + addUniquePath(paths, userHome.getChildFile(".lv2")); + addEnvironmentSearchPath(paths, "LV2_PATH"); + } + else if (formatName.equalsIgnoreCase("CLAP")) + { + addUniquePath(paths, juce::File("/Library/Audio/Plug-Ins/CLAP")); + addUniquePath(paths, userHome.getChildFile("Library/Audio/Plug-Ins/CLAP")); + addEnvironmentSearchPath(paths, "CLAP_PATH"); + } + #else + const auto userHome = juce::File::getSpecialLocation(juce::File::userHomeDirectory); + + if (formatName.containsIgnoreCase("VST3")) + { + addUniquePath(paths, juce::File("/usr/lib/vst3")); + addUniquePath(paths, juce::File("/usr/local/lib/vst3")); + addUniquePath(paths, userHome.getChildFile(".vst3")); + addUniquePath(paths, executableDirectory.getChildFile("VST3")); + addEnvironmentSearchPath(paths, "VST3_PATH"); + } + else if (formatName.containsIgnoreCase("LV2")) + { + addUniquePath(paths, juce::File("/usr/lib/lv2")); + addUniquePath(paths, juce::File("/usr/local/lib/lv2")); + addUniquePath(paths, userHome.getChildFile(".lv2")); + addEnvironmentSearchPath(paths, "LV2_PATH"); + } + else if (formatName.equalsIgnoreCase("CLAP")) + { + addUniquePath(paths, juce::File("/usr/lib/clap")); + addUniquePath(paths, juce::File("/usr/local/lib/clap")); + addUniquePath(paths, userHome.getChildFile(".clap")); + addEnvironmentSearchPath(paths, "CLAP_PATH"); + } + #endif + + for (const auto& customPath : customPaths) + addUniquePath(paths, customPath); + + return paths; +} + +juce::FileSearchPath makeFileSearchPath(const juce::StringArray& paths) +{ + juce::FileSearchPath result; + for (const auto& path : paths) + result.add(juce::File(path)); + return result; +} + +juce::StringArray getDeduplicatedCandidates(juce::AudioPluginFormat& format, + const juce::StringArray& paths) +{ + auto discovered = format.searchPathsForPlugins(makeFileSearchPath(paths), true, false); + juce::StringArray result; + + for (const auto& candidate : discovered) + { + auto normalised = normaliseCandidateIdentifier(candidate); + if (normalised.isNotEmpty()) + addUniqueCandidateIdentifier(result, normalised); + } + + return result; +} + +juce::var makeStringArrayVar(const juce::StringArray& values) +{ + juce::Array<juce::var> result; + result.ensureStorageAllocated(values.size()); + for (const auto& value : values) + result.add(value); + return juce::var(std::move(result)); +} + +void migrateLegacyFile(const juce::File& legacyFile, const juce::File& destinationFile) +{ + if (destinationFile.existsAsFile() || !legacyFile.existsAsFile()) + return; + + destinationFile.getParentDirectory().createDirectory(); + if (legacyFile.copyFileTo(destinationFile)) + { + juce::Logger::writeToLog("PluginManager: Migrated " + legacyFile.getFullPathName() + + " to " + destinationFile.getFullPathName()); + } + else + { + juce::Logger::writeToLog("PluginManager: Failed to migrate " + legacyFile.getFullPathName() + + " to " + destinationFile.getFullPathName()); + } +} + +juce::String getCanonicalPluginIdentifier(const juce::String& identifier) +{ + auto normalised = normaliseCandidateIdentifier(identifier); + if (!juce::File::isAbsolutePath(normalised)) + return normalised; + + auto current = juce::File(normalised); + auto canonicalBundle = current; + bool foundBundle = false; + + for (;;) + { + const auto extension = current.getFileExtension(); + if (extension.equalsIgnoreCase(".vst3") + || extension.equalsIgnoreCase(".lv2") + || extension.equalsIgnoreCase(".clap")) + { + canonicalBundle = current; + foundBundle = true; + } + + const auto parent = current.getParentDirectory(); + if (parent == current) + break; + current = parent; + } + + return foundBundle ? canonicalBundle.getFullPathName() : normalised; +} + +bool identifiersMatch(const juce::String& first, const juce::String& second) +{ + const auto normalisedFirst = getCanonicalPluginIdentifier(first); + const auto normalisedSecond = getCanonicalPluginIdentifier(second); + return candidateIdentifiersEqual(normalisedFirst, normalisedSecond); +} + +int indexOfPluginIdentifier(const juce::StringArray& values, + const juce::String& value) +{ + for (int i = 0; i < values.size(); ++i) + if (identifiersMatch(values[i], value)) + return i; + return -1; +} + +bool containsPluginIdentifier(const juce::StringArray& values, + const juce::String& value) +{ + return indexOfPluginIdentifier(values, value) >= 0; +} + +bool descriptionMatchesCandidate(const juce::PluginDescription& description, + const juce::String& formatName, + const juce::String& candidate) +{ + return description.pluginFormatName.equalsIgnoreCase(formatName) + && identifiersMatch(description.fileOrIdentifier, candidate); +} + +void removeDescriptionsForCandidate(juce::KnownPluginList& list, + const juce::String& formatName, + const juce::String& candidate) +{ + const auto descriptions = list.getTypes(); + for (const auto& description : descriptions) + if (descriptionMatchesCandidate(description, formatName, candidate)) + list.removeType(description); +} + +class PluginProbeState final +{ +public: + void setFailure(const juce::String& identifier, const juce::String& reason) + { + const auto existingIndex = indexOfCandidateIdentifier(identifiers, identifier); + if (existingIndex >= 0) + { + reasons.set(existingIndex, reason); + return; + } + + identifiers.add(identifier); + reasons.add(reason); + } + + juce::String getFailure(const juce::String& identifier) const + { + const auto index = indexOfCandidateIdentifier(identifiers, identifier); + return index >= 0 ? reasons[index] : juce::String(); + } + + const juce::StringArray& getIdentifiers() const noexcept + { + return identifiers; + } + + void setInfrastructureFailure(const juce::String& reason) + { + if (infrastructureFailure.isEmpty()) + infrastructureFailure = reason; + } + + bool hadInfrastructureFailure() const noexcept + { + return infrastructureFailure.isNotEmpty(); + } + + const juce::String& getInfrastructureFailure() const noexcept + { + return infrastructureFailure; + } + +private: + juce::StringArray identifiers; + juce::StringArray reasons; + juce::String infrastructureFailure; +}; + +class ScopedPluginProbeArtifacts final +{ +public: + ScopedPluginProbeArtifacts() + : reportFile(juce::File::createTempFile(".openstudio-plugin-scan.xml")), + logFile(reportFile.withFileExtension("log")) + { + reportFile.deleteFile(); + logFile.deleteFile(); + } + + ~ScopedPluginProbeArtifacts() + { + reportFile.deleteFile(); + logFile.deleteFile(); + } + + juce::String appendLogTail(const juce::String& reason) const + { + auto input = logFile.createInputStream(); + if (input == nullptr) + return reason; + + constexpr juce::int64 maximumTailBytes = 2048; + const auto streamLength = input->getTotalLength(); + if (streamLength > maximumTailBytes) + input->setPosition(streamLength - maximumTailBytes); + + const auto tail = input->readEntireStreamAsString().trim(); + return tail.isEmpty() ? reason : reason + " Probe log tail:\n" + tail; + } + + juce::File reportFile; + juce::File logFile; +}; + +class OutOfProcessPluginScanner final : public juce::KnownPluginList::CustomScanner +{ +public: + OutOfProcessPluginScanner(std::shared_ptr<PluginProbeState> stateToUse, + const juce::StringArray& effectiveSearchPaths) + : state(std::move(stateToUse)), + searchPathsFile(juce::File::createTempFile(".openstudio-plugin-paths.xml")) + { + searchPathsFile.deleteFile(); + + juce::XmlElement root("PLUGIN_SEARCH_PATHS"); + for (const auto& path : effectiveSearchPaths) + root.createNewChildElement("PATH")->setAttribute("value", path); + + if (!root.writeTo(searchPathsFile)) + state->setInfrastructureFailure("The isolated scanner search-path manifest could not be created."); + } + + ~OutOfProcessPluginScanner() override + { + searchPathsFile.deleteFile(); + } + + bool findPluginTypesFor(juce::AudioPluginFormat& format, + juce::OwnedArray<juce::PluginDescription>& result, + const juce::String& fileOrIdentifier) override + { + constexpr int timeoutMs = 20000; + constexpr int pollIntervalMs = 10; + + if (state->hadInfrastructureFailure()) + { + state->setFailure(fileOrIdentifier, state->getInfrastructureFailure()); + return false; + } + + ScopedPluginProbeArtifacts artifacts; + + juce::StringArray arguments; + arguments.add(juce::File::getSpecialLocation(juce::File::currentExecutableFile).getFullPathName()); + arguments.add("--plugin-scan-probe-headless"); + arguments.add(fileOrIdentifier); + arguments.add("--plugin-format"); + arguments.add(format.getName()); + arguments.add("--plugin-search-paths-file"); + arguments.add(searchPathsFile.getFullPathName()); + arguments.add("--report"); + arguments.add(artifacts.reportFile.getFullPathName()); + + juce::ChildProcess process; + if (!process.start(arguments, 0)) + { + const auto reason = artifacts.appendLogTail( + "The isolated plugin scanner process could not be started."); + state->setFailure(fileOrIdentifier, reason); + state->setInfrastructureFailure(reason); + return false; + } + + int elapsedMs = 0; + while (process.isRunning() && elapsedMs < timeoutMs && !shouldExit()) + { + juce::Thread::sleep(pollIntervalMs); + elapsedMs += pollIntervalMs; + } + + if (process.isRunning()) + { + const bool cancelled = shouldExit(); + process.kill(); + process.waitForProcessToFinish(500); + const auto reason = cancelled + ? juce::String("The plugin scan was cancelled.") + : juce::String("The isolated plugin scanner timed out after 20 seconds and was terminated."); + state->setFailure(fileOrIdentifier, artifacts.appendLogTail(reason)); + return false; + } + + const auto exitCode = process.getExitCode(); + auto report = juce::parseXML(artifacts.reportFile); + + if (report == nullptr || !report->hasTagName("PLUGIN_SCAN_RESULT")) + { + state->setFailure( + fileOrIdentifier, + artifacts.appendLogTail( + "The isolated plugin scanner exited without a valid report (exit code " + + juce::String(static_cast<juce::int64>(exitCode)) + ").")); + return false; + } + + const auto status = report->getStringAttribute("status"); + const auto reportedError = report->getStringAttribute("error"); + if (status != "ok") + { + const auto reason = reportedError.isNotEmpty() + ? reportedError + : "The isolated scanner returned status '" + status + "'."; + state->setFailure(fileOrIdentifier, artifacts.appendLogTail(reason)); + // The helper completed and returned a valid diagnostic. Treat this + // as a normal scan failure, rather than a scanner crash blacklist. + return true; + } + + if (exitCode != 0) + { + state->setFailure( + fileOrIdentifier, + artifacts.appendLogTail( + "The isolated scanner returned a successful report but exited with code " + + juce::String(static_cast<juce::int64>(exitCode)) + ".")); + return false; + } + + int descriptionCount = 0; + for (auto* child : report->getChildIterator()) + { + juce::PluginDescription description; + if (!description.loadFromXml(*child)) + { + state->setFailure( + fileOrIdentifier, + artifacts.appendLogTail( + "The isolated scanner returned malformed plugin-description data.")); + result.clear(); + return false; + } + + result.add(new juce::PluginDescription(description)); + ++descriptionCount; + } + + if (descriptionCount <= 0 + || descriptionCount != report->getIntAttribute("pluginCount", descriptionCount)) + { + state->setFailure( + fileOrIdentifier, + artifacts.appendLogTail( + "The isolated scanner report contained an inconsistent plugin count.")); + result.clear(); + return false; + } + + return true; + } + +private: + std::shared_ptr<PluginProbeState> state; + juce::File searchPathsFile; +}; } PluginManager::PluginManager() { // Add default formats (VST3, LV2, AU, etc.) - formatManager.addDefaultFormats(); + juce::addDefaultFormatsToManager(formatManager); // Add CLAP hosting (not built into JUCE — custom format) - formatManager.addFormat(new CLAPPluginFormat()); + formatManager.addFormat(std::make_unique<CLAPPluginFormat>()); // Debug: Log how many formats were added juce::Logger::writeToLog("PluginManager: Constructor - formatManager has " + @@ -35,18 +619,33 @@ PluginManager::PluginManager() juce::Logger::writeToLog("PluginManager: Format " + juce::String(i) + ": " + format->getName()); } - pluginListFile = getOpenStudioDocumentsDirectory().getChildFile("PluginList.xml"); - auto legacyPluginListFile = getLegacyStudio13DocumentsDirectory().getChildFile("PluginList.xml"); - if (!pluginListFile.existsAsFile() && legacyPluginListFile.existsAsFile()) - pluginListFile = legacyPluginListFile; + const auto openStudioDirectory = getOpenStudioDocumentsDirectory(); + const auto legacyStudio13Directory = getLegacyStudio13DocumentsDirectory(); + openStudioDirectory.createDirectory(); + + pluginListFile = openStudioDirectory.getChildFile("PluginList.xml"); + blacklistFile = openStudioDirectory.getChildFile("PluginBlacklist.txt"); + pluginSearchPathsFile = openStudioDirectory.getChildFile("PluginSearchPaths.xml"); + pluginScanDeadMansPedalFile = openStudioDirectory.getChildFile("PluginScanDeadMansPedal.txt"); - blacklistFile = getOpenStudioDocumentsDirectory().getChildFile("PluginBlacklist.txt"); - auto legacyBlacklistFile = getLegacyStudio13DocumentsDirectory().getChildFile("PluginBlacklist.txt"); - if (!blacklistFile.existsAsFile() && legacyBlacklistFile.existsAsFile()) - blacklistFile = legacyBlacklistFile; + // Copy legacy state once, but never continue reading or writing the legacy + // files. This prevents the old Studio13 location from remaining sticky. + migrateLegacyFile(legacyStudio13Directory.getChildFile("PluginList.xml"), pluginListFile); + migrateLegacyFile(legacyStudio13Directory.getChildFile("PluginBlacklist.txt"), blacklistFile); if (blacklistFile.existsAsFile()) + { blacklistFile.readLines(blacklistedPlugins); + blacklistedPlugins.trim(); + blacklistedPlugins.removeEmptyStrings(); + juce::StringArray deduplicatedBlacklist; + for (const auto& pluginId : blacklistedPlugins) + if (!containsPluginIdentifier(deduplicatedBlacklist, pluginId)) + deduplicatedBlacklist.add(pluginId); + blacklistedPlugins = std::move(deduplicatedBlacklist); + } + + loadPluginSearchPaths(); // Load existing plugin list if available loadPluginList(); @@ -60,126 +659,351 @@ PluginManager::~PluginManager() savePluginList(); } -void PluginManager::scanForPlugins() +juce::var PluginManager::scanForPlugins(bool forceRescan) { - // Create debug log file - juce::File debugLog = getOpenStudioDocumentsDirectory().getChildFile("plugin_scan_debug.txt"); + // Only one background scan may run at a time. The main manager lock is + // deliberately held only for short snapshots and the final commit so UI + // calls never wait behind third-party probe timeouts. + const juce::ScopedLock serialisedScanLock(pluginScanLock); + + juce::StringArray customPathsSnapshot; + juce::StringArray blacklistSnapshot; + std::unique_ptr<juce::XmlElement> currentCatalog; + juce::uint64 stateRevisionAtStart = 0; + { + const juce::ScopedLock managerLock(pluginManagerLock); + customPathsSnapshot = customPluginSearchPaths; + blacklistSnapshot = blacklistedPlugins; + currentCatalog = knownPluginList.createXml(); + stateRevisionAtStart = pluginStateRevision; + } + + juce::AudioPluginFormatManager scanFormatManager; + juce::addDefaultFormatsToManager(scanFormatManager); + scanFormatManager.addFormat(std::make_unique<CLAPPluginFormat>()); + + const auto debugLog = getOpenStudioDocumentsDirectory().getChildFile("plugin_scan_debug.txt"); debugLog.getParentDirectory().createDirectory(); debugLog.deleteFile(); debugLog.create(); - - auto writeLog = [&debugLog](const juce::String& message) { + + const auto writeLog = [&debugLog](const juce::String& message) + { juce::Logger::writeToLog(message); debugLog.appendText(message + "\n"); }; - - writeLog("PluginManager: Starting plugin scan..."); - writeLog("PluginManager: Number of formats available: " + juce::String(formatManager.getNumFormats())); - - // Clear existing list - knownPluginList.clear(); - - // Scan for each plugin format - for (int i = 0; i < formatManager.getNumFormats(); ++i) + + writeLog("PluginManager: Starting transactional, isolated plugin scan [mode=" + + juce::String(forceRescan ? "deep" : "cached") + "]"); + writeLog("PluginManager: Supported format count: " + juce::String(scanFormatManager.getNumFormats())); + + juce::KnownPluginList scannedPluginList; + if (currentCatalog != nullptr) + scannedPluginList.recreateFromXml(*currentCatalog); + scannedPluginList.clearBlacklistedFiles(); + + struct ActiveCandidate { - auto* format = formatManager.getFormat(i); - writeLog("PluginManager: Scanning " + format->getName() + " plugins..."); - - // Get default plugin search paths for this format - juce::FileSearchPath searchPaths = format->getDefaultLocationsToSearch(); - - // Add additional common plugin locations manually - if (format->getName().contains("VST3")) - { - // Common VST3 locations on Windows - searchPaths.add(juce::File("C:\\Program Files\\Common Files\\VST3")); - searchPaths.add(juce::File("C:\\Program Files\\Steinberg\\VstPlugins")); - searchPaths.add(juce::File("C:\\Program Files\\VSTPlugins")); - searchPaths.add(juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) - .getChildFile("VST3")); - } - else if (format->getName().contains("LV2")) - { - // Common LV2 locations on Windows - searchPaths.add(juce::File("C:\\Program Files\\Common Files\\LV2")); - searchPaths.add(juce::File("C:\\Program Files\\LV2")); - searchPaths.add(juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory) - .getChildFile("LV2")); - // User home .lv2 - searchPaths.add(juce::File::getSpecialLocation(juce::File::userHomeDirectory) - .getChildFile(".lv2")); - } - else if (format->getName() == "CLAP") - { - // Common CLAP locations on Windows - searchPaths.add(juce::File("C:\\Program Files\\Common Files\\CLAP")); - } - - writeLog("PluginManager: Search paths: " + searchPaths.toString()); - writeLog("PluginManager: Number of search paths: " + juce::String(searchPaths.getNumPaths())); - - // Log each individual path - for (int p = 0; p < searchPaths.getNumPaths(); ++p) - { - auto path = searchPaths[p]; - writeLog("PluginManager: Path " + juce::String(p) + ": " + path.getFullPathName()); - writeLog("PluginManager: Path exists: " + juce::String(path.exists() ? "YES" : "NO")); - if (path.exists()) + juce::String format; + juce::String identifier; + }; + + std::vector<ActiveCandidate> activeCandidates; + juce::Array<juce::var> formatReports; + juce::Array<juce::var> failures; + juce::Array<juce::var> skipped; + juce::StringArray allSearchPaths; + int totalCandidateCount = 0; + int totalFailedCount = 0; + int totalSkippedCount = 0; + bool scanInfrastructureHealthy = true; + juce::String infrastructureError; + + for (int i = 0; i < scanFormatManager.getNumFormats(); ++i) + { + auto* format = scanFormatManager.getFormat(i); + if (format == nullptr) + continue; + + const auto formatName = format->getName(); + const auto searchPaths = getSearchPathsForFormat(*format, customPathsSnapshot); + for (const auto& path : searchPaths) + allSearchPaths.addIfNotAlreadyThere(path, shouldIgnorePathCase()); + + writeLog({}); + writeLog("PluginManager: Format: " + formatName); + for (const auto& path : searchPaths) + { + const auto directory = juce::File(path); + writeLog("PluginManager: Path: " + path + + " [" + (directory.isDirectory() ? "available" : "not found") + "]"); + } + + const auto candidates = getDeduplicatedCandidates(*format, searchPaths); + juce::StringArray allowedCandidates; + juce::Array<juce::var> formatSkipped; + for (const auto& candidate : candidates) + { + if (containsPluginIdentifier(blacklistSnapshot, candidate)) { - writeLog("PluginManager: Path is directory: " + juce::String(path.isDirectory() ? "YES" : "NO")); - - // List files in this directory - juce::Array<juce::File> files; - path.findChildFiles(files, juce::File::findFilesAndDirectories, true, "*.vst3;*.lv2"); - writeLog("PluginManager: Found " + juce::String(files.size()) + " plugin files in this path"); + const auto reason = "Skipped because this candidate is in the persisted user or legacy plug-in blacklist."; + auto* skippedCandidate = new juce::DynamicObject(); + skippedCandidate->setProperty("format", formatName); + skippedCandidate->setProperty("path", candidate); + skippedCandidate->setProperty("reason", reason); + const juce::var skippedValue(skippedCandidate); + formatSkipped.add(skippedValue); + skipped.add(skippedValue); + ++totalSkippedCount; + writeLog("PluginManager: Skipped blacklisted candidate: " + candidate + " - " + reason); + removeDescriptionsForCandidate(scannedPluginList, formatName, candidate); + continue; } + + allowedCandidates.add(candidate); + activeCandidates.push_back({ formatName, candidate }); } - - // Get all plugin files in these locations - auto fileOrIdentifiers = format->searchPathsForPlugins(searchPaths, true, false); - - writeLog("PluginManager: Found " + juce::String(fileOrIdentifiers.size()) + " potential plugin files"); - - // Scan each plugin - int foundCount = 0; - for (const auto& fileOrIdentifier : fileOrIdentifiers) - { - writeLog("PluginManager: Checking: " + fileOrIdentifier); - - if (format->fileMightContainThisPluginType(fileOrIdentifier)) + + totalCandidateCount += candidates.size(); + writeLog("PluginManager: Candidate count (deduplicated): " + juce::String(candidates.size())); + + juce::StringArray candidatesNeedingProbe; + const auto cachedDescriptions = scannedPluginList.getTypes(); + for (const auto& candidate : allowedCandidates) + { + bool hasCachedDescription = false; + bool needsRescan = false; + + for (const auto& description : cachedDescriptions) { - juce::OwnedArray<juce::PluginDescription> foundDescriptions; - format->findAllTypesForFile(foundDescriptions, fileOrIdentifier); - - for (auto* desc : foundDescriptions) + if (!descriptionMatchesCandidate(description, formatName, candidate)) + continue; + + hasCachedDescription = true; + if (format->pluginNeedsRescanning(description)) + needsRescan = true; + } + + if (forceRescan || !hasCachedDescription || needsRescan) + { + removeDescriptionsForCandidate(scannedPluginList, formatName, candidate); + candidatesNeedingProbe.add(candidate); + } + } + + writeLog("PluginManager: Reused unchanged candidates: " + + juce::String(allowedCandidates.size() - candidatesNeedingProbe.size())); + writeLog("PluginManager: Candidates requiring isolated probe: " + + juce::String(candidatesNeedingProbe.size())); + + auto probeState = std::make_shared<PluginProbeState>(); + scannedPluginList.setCustomScanner( + std::make_unique<OutOfProcessPluginScanner>(probeState, searchPaths)); + + juce::StringArray scannerFailures; + { + // Candidate enumeration was already performed above. Start the JUCE + // scanner with an empty path and provide the exact canonical list. + juce::PluginDirectoryScanner scanner(scannedPluginList, + *format, + juce::FileSearchPath(), + true, + pluginScanDeadMansPedalFile, + false); + scanner.setFilesOrIdentifiersToScan(candidatesNeedingProbe); + + if (!candidatesNeedingProbe.isEmpty()) + { + bool hasMoreFiles = true; + while (hasMoreFiles) + { + juce::String pluginBeingScanned; + hasMoreFiles = scanner.scanNextFile(true, pluginBeingScanned); + if (pluginBeingScanned.isNotEmpty()) + writeLog("PluginManager: Isolated probe completed: " + pluginBeingScanned); + } + } + + scannerFailures.addArray(scanner.getFailedFiles()); + } + + juce::StringArray failedCandidates; + for (const auto& failed : scannerFailures) + addUniqueCandidateIdentifier(failedCandidates, failed); + for (const auto& failed : probeState->getIdentifiers()) + addUniqueCandidateIdentifier(failedCandidates, failed); + for (const auto& candidate : candidatesNeedingProbe) + { + if (containsCandidateIdentifier(scannedPluginList.getBlacklistedFiles(), candidate)) + addUniqueCandidateIdentifier(failedCandidates, candidate); + } + + for (const auto& failedCandidate : failedCandidates) + { + removeDescriptionsForCandidate(scannedPluginList, formatName, failedCandidate); + + auto reason = probeState->getFailure(failedCandidate); + if (reason.isEmpty()) + reason = "A previous scan was interrupted while inspecting this plugin."; + + auto* failure = new juce::DynamicObject(); + failure->setProperty("format", formatName); + failure->setProperty("path", failedCandidate); + failure->setProperty("reason", reason); + failures.add(juce::var(failure)); + writeLog("PluginManager: Failed: " + failedCandidate + " - " + reason); + } + + int formatPluginCount = 0; + for (const auto& description : scannedPluginList.getTypes()) + { + if (!description.pluginFormatName.equalsIgnoreCase(formatName)) + continue; + + for (const auto& candidate : allowedCandidates) + { + if (descriptionMatchesCandidate(description, formatName, candidate)) { - knownPluginList.addType(*desc); - foundCount++; - writeLog("PluginManager: ✓ Found plugin: " + desc->name + " by " + desc->manufacturerName); + ++formatPluginCount; + writeLog("PluginManager: Found: " + description.name + " by " + + description.manufacturerName + " [" + description.fileOrIdentifier + "]"); + break; } } - else + } + + const int failedCount = failedCandidates.size(); + const int formatSkippedCount = formatSkipped.size(); + totalFailedCount += failedCount; + + auto* formatReport = new juce::DynamicObject(); + formatReport->setProperty("format", formatName); + formatReport->setProperty("candidateCount", candidates.size()); + formatReport->setProperty("pluginCount", formatPluginCount); + formatReport->setProperty("failedCount", failedCount); + formatReport->setProperty("skippedCount", formatSkippedCount); + formatReport->setProperty("skipped", juce::var(std::move(formatSkipped))); + formatReport->setProperty("paths", makeStringArrayVar(searchPaths)); + formatReports.add(juce::var(formatReport)); + + writeLog("PluginManager: Format result: " + juce::String(formatPluginCount) + + " plugin descriptions, " + juce::String(failedCount) + " failures, " + + juce::String(formatSkippedCount) + " skipped"); + + // Failure to launch the helper is an infrastructure failure, not a bad + // plugin. Preserve the existing catalog instead of committing an empty + // or partial replacement. + if (probeState->hadInfrastructureFailure()) + { + scanInfrastructureHealthy = false; + if (infrastructureError.isEmpty()) + infrastructureError = probeState->getInfrastructureFailure(); + } + } + + scannedPluginList.setCustomScanner(std::unique_ptr<juce::KnownPluginList::CustomScanner>()); + + // Remove stale entries for uninstalled plugins, paths that are no longer + // configured, unsupported formats, and user-blacklisted candidates. + const auto scannedDescriptions = scannedPluginList.getTypes(); + for (const auto& description : scannedDescriptions) + { + bool isActive = false; + for (const auto& candidate : activeCandidates) + { + if (descriptionMatchesCandidate(description, candidate.format, candidate.identifier)) { - writeLog("PluginManager: ✗ File does not contain this plugin type"); + isActive = true; + break; } } - - writeLog("PluginManager: Format " + format->getName() + " scan complete. Found " + juce::String(foundCount) + " plugins"); + + if (!isActive) + { + writeLog("PluginManager: Pruned stale catalog entry: " + description.name + + " [" + description.fileOrIdentifier + + "] because its candidate is no longer present in the effective search paths or is blacklisted."); + scannedPluginList.removeType(description); + } + } + scannedPluginList.clearBlacklistedFiles(); + + bool committed = false; + if (scanInfrastructureHealthy) + { + if (auto scannedXml = scannedPluginList.createXml()) + { + { + const juce::ScopedLock managerLock(pluginManagerLock); + if (pluginStateRevision != stateRevisionAtStart) + { + infrastructureError = "Plugin paths, blacklist, or catalog changed while the scan was running; the stale scan was not committed."; + } + else + { + auto previousCatalog = knownPluginList.createXml(); + knownPluginList.recreateFromXml(*scannedXml); + if (savePluginList()) + { + ++pluginStateRevision; + committed = true; + } + else + { + if (previousCatalog != nullptr) + knownPluginList.recreateFromXml(*previousCatalog); + infrastructureError = "The completed plugin catalog could not be saved."; + } + } + } + } + else + { + infrastructureError = "The completed plugin catalog could not be serialised."; + } } - - writeLog("PluginManager: ========================================"); - writeLog("PluginManager: SCAN COMPLETE. Total plugins found: " + - juce::String(knownPluginList.getNumTypes())); - writeLog("PluginManager: ========================================"); - writeLog("PluginManager: Debug log saved to: " + debugLog.getFullPathName()); - savePluginList(); - // Also scan for S13FX/JSFX scripts scanForS13FX(); + + int pluginCount = scannedPluginList.getNumTypes(); + if (!committed) + { + const juce::ScopedLock managerLock(pluginManagerLock); + pluginCount = knownPluginList.getNumTypes(); + } + writeLog({}); + writeLog("PluginManager: Scan " + juce::String(committed ? "committed" : "not committed")); + writeLog("PluginManager: Plugin descriptions: " + juce::String(pluginCount)); + writeLog("PluginManager: Candidates: " + juce::String(totalCandidateCount)); + writeLog("PluginManager: Failures: " + juce::String(totalFailedCount)); + writeLog("PluginManager: Skipped blacklisted candidates: " + juce::String(totalSkippedCount)); + writeLog("PluginManager: Debug log: " + debugLog.getFullPathName()); + + auto* report = new juce::DynamicObject(); + report->setProperty("success", committed); + report->setProperty("forceRescan", forceRescan); + if (!committed) + { + report->setProperty("error", + infrastructureError.isNotEmpty() + ? infrastructureError + : "The plugin scan could not be committed."); + } + report->setProperty("pluginCount", pluginCount); + report->setProperty("candidateCount", totalCandidateCount); + report->setProperty("failedCount", totalFailedCount); + report->setProperty("skippedCount", totalSkippedCount); + report->setProperty("paths", makeStringArrayVar(allSearchPaths)); + report->setProperty("failures", juce::var(std::move(failures))); + report->setProperty("skipped", juce::var(std::move(skipped))); + report->setProperty("formats", juce::var(std::move(formatReports))); + report->setProperty("debugLogPath", debugLog.getFullPathName()); + return juce::var(report); } juce::Array<juce::PluginDescription> PluginManager::getAvailablePlugins() const { + const juce::ScopedLock managerLock(pluginManagerLock); juce::Array<juce::PluginDescription> plugins; for (const auto& type : knownPluginList.getTypes()) @@ -190,9 +1014,17 @@ juce::Array<juce::PluginDescription> PluginManager::getAvailablePlugins() const return plugins; } +std::vector<S13FXInfo> PluginManager::getAvailableS13FX() const +{ + const juce::ScopedLock managerLock(pluginManagerLock); + return s13fxList; +} + std::unique_ptr<juce::AudioProcessor> PluginManager::loadPlugin(const juce::PluginDescription& description, double sampleRate, int blockSize) { + const juce::ScopedLock managerLock(pluginManagerLock); + // Refuse to load blacklisted plugins (previously crashed) if (isPluginBlacklisted(description.fileOrIdentifier)) { @@ -200,6 +1032,21 @@ std::unique_ptr<juce::AudioProcessor> PluginManager::loadPlugin(const juce::Plug return nullptr; } + if (liveLv2PathsNeedPriming && description.pluginFormatName.containsIgnoreCase("LV2")) + { + for (int i = 0; i < formatManager.getNumFormats(); ++i) + { + auto* format = formatManager.getFormat(i); + if (format != nullptr && format->getName().equalsIgnoreCase(description.pluginFormatName)) + { + const auto lv2Paths = getSearchPathsForFormat(*format, customPluginSearchPaths); + format->searchPathsForPlugins(makeFileSearchPath(lv2Paths), true, false); + liveLv2PathsNeedPriming = false; + break; + } + } + } + juce::String errorMessage; // Clamp block size to at least 512 — ASIO buffers can be as small as 32 samples, @@ -227,57 +1074,84 @@ std::unique_ptr<juce::AudioProcessor> PluginManager::loadPlugin(const juce::Plug std::unique_ptr<juce::AudioProcessor> PluginManager::loadPluginFromFile(const juce::String& filePath, double sampleRate, int blockSize) { + const juce::ScopedLock managerLock(pluginManagerLock); juce::Logger::writeToLog("PluginManager: Loading plugin from: " + filePath); - // 1. Exact match in known list + // A JUCE identifier string selects an exact class, including bundles that + // expose multiple plugin classes from one filesystem module. + if (auto exactDescription = knownPluginList.getTypeForIdentifierString(filePath)) + { + juce::Logger::writeToLog("PluginManager: Exact catalog identifier found"); + return loadPlugin(*exactDescription, sampleRate, blockSize); + } + + // Retain backwards compatibility with projects that stored a module path. for (const auto& desc : knownPluginList.getTypes()) { - if (desc.fileOrIdentifier == filePath) + const bool isExactPath = shouldIgnorePathCase() + ? desc.fileOrIdentifier.equalsIgnoreCase(filePath) + : desc.fileOrIdentifier == filePath; + if (isExactPath) { juce::Logger::writeToLog("PluginManager: Exact match found in known list"); return loadPlugin(desc, sampleRate, blockSize); } } - // 2. Partial match — the saved fileOrIdentifier from a loaded plugin instance - // may be the inner module path (e.g. .../Contents/x86_64-win/Plugin.vst3) - // while the known list stores the bundle path (e.g. .../Plugin.vst3). - // Try matching if one path contains the other. + // Older projects may contain the inner binary path while the catalog stores + // the outer bundle. Compare canonical bundle boundaries, not substrings. for (const auto& desc : knownPluginList.getTypes()) { - if (filePath.contains(desc.fileOrIdentifier) || desc.fileOrIdentifier.contains(filePath)) + if (identifiersMatch(filePath, desc.fileOrIdentifier)) { - juce::Logger::writeToLog("PluginManager: Partial match found: " + desc.fileOrIdentifier); + juce::Logger::writeToLog("PluginManager: Canonical bundle match found: " + desc.fileOrIdentifier); return loadPlugin(desc, sampleRate, blockSize); } } - // 3. Direct scan — try to load from the file path directly - juce::File pluginFile(filePath); - // Walk up to find the plugin bundle directory if we have an inner path - juce::File bundleFile = pluginFile; - while (bundleFile.getParentDirectory() != bundleFile && - bundleFile.getFileExtension() != ".vst3" && - bundleFile.getFileExtension() != ".lv2" && - bundleFile.getFileExtension() != ".clap") - { - bundleFile = bundleFile.getParentDirectory(); - } + // Unknown paths are metadata-scanned in a disposable helper process. + const auto candidate = getCanonicalPluginIdentifier(filePath); for (int i = 0; i < formatManager.getNumFormats(); ++i) { auto* format = formatManager.getFormat(i); - juce::String candidate = bundleFile.getFullPathName(); if (format->fileMightContainThisPluginType(candidate)) { + auto probeState = std::make_shared<PluginProbeState>(); + OutOfProcessPluginScanner scanner( + probeState, + getSearchPathsForFormat(*format, customPluginSearchPaths)); juce::OwnedArray<juce::PluginDescription> descriptions; - format->findAllTypesForFile(descriptions, candidate); + const bool probeCompleted = scanner.findPluginTypesFor(*format, descriptions, candidate); + if (!probeCompleted) + { + juce::Logger::writeToLog("PluginManager: Isolated direct scan failed: " + + probeState->getFailure(candidate)); + return nullptr; + } + if (descriptions.size() > 0) { juce::Logger::writeToLog("PluginManager: Direct scan found: " + descriptions[0]->name); - knownPluginList.addType(*descriptions[0]); + auto previousCatalog = knownPluginList.createXml(); + for (const auto* description : descriptions) + if (description != nullptr) + knownPluginList.addType(*description); + + if (savePluginList()) + { + ++pluginStateRevision; + } + else if (previousCatalog != nullptr) + { + knownPluginList.recreateFromXml(*previousCatalog); + } return loadPlugin(*descriptions[0], sampleRate, blockSize); } + + const auto reason = probeState->getFailure(candidate); + if (reason.isNotEmpty()) + juce::Logger::writeToLog("PluginManager: Isolated direct scan rejected the candidate: " + reason); } } @@ -285,18 +1159,28 @@ std::unique_ptr<juce::AudioProcessor> PluginManager::loadPluginFromFile(const ju return nullptr; } -void PluginManager::savePluginList() +bool PluginManager::savePluginList() { + const juce::ScopedLock managerLock(pluginManagerLock); if (auto xml = knownPluginList.createXml()) { - // Create parent directory if needed pluginListFile.getParentDirectory().createDirectory(); - - if (xml->writeTo(pluginListFile)) + juce::TemporaryFile temporaryFile(pluginListFile); + + if (xml->writeTo(temporaryFile.getFile()) + && temporaryFile.overwriteTargetFileWithTemporary()) { juce::Logger::writeToLog("PluginManager: Saved plugin list to " + pluginListFile.getFullPathName()); + return true; + } + else + { + juce::Logger::writeToLog("PluginManager: Failed to save plugin list to " + + pluginListFile.getFullPathName()); } } + + return false; } void PluginManager::loadPluginList() @@ -316,6 +1200,141 @@ void PluginManager::loadPluginList() } } +bool PluginManager::savePluginSearchPaths() const +{ + pluginSearchPathsFile.getParentDirectory().createDirectory(); + + juce::XmlElement root("PLUGIN_SEARCH_PATHS"); + root.setAttribute("version", 1); + for (const auto& path : customPluginSearchPaths) + { + auto* child = root.createNewChildElement("PATH"); + child->setAttribute("value", path); + } + + juce::TemporaryFile temporaryFile(pluginSearchPathsFile); + if (!root.writeTo(temporaryFile.getFile())) + return false; + + return temporaryFile.overwriteTargetFileWithTemporary(); +} + +void PluginManager::loadPluginSearchPaths() +{ + customPluginSearchPaths.clear(); + if (!pluginSearchPathsFile.existsAsFile()) + return; + + auto xml = juce::parseXML(pluginSearchPathsFile); + if (xml == nullptr || !xml->hasTagName("PLUGIN_SEARCH_PATHS")) + { + juce::Logger::writeToLog("PluginManager: Ignoring malformed plugin search-path file: " + + pluginSearchPathsFile.getFullPathName()); + return; + } + + for (auto* child : xml->getChildIterator()) + { + if (!child->hasTagName("PATH")) + continue; + + addUniquePath(customPluginSearchPaths, child->getStringAttribute("value")); + } +} + +juce::var PluginManager::getPluginScanConfiguration() const +{ + const juce::ScopedLock managerLock(pluginManagerLock); + + juce::StringArray supportedFormats; + juce::Array<juce::var> effectivePaths; + + for (int i = 0; i < formatManager.getNumFormats(); ++i) + { + auto* format = formatManager.getFormat(i); + if (format == nullptr) + continue; + + const auto formatName = format->getName(); + supportedFormats.addIfNotAlreadyThere(formatName, true); + const auto paths = getSearchPathsForFormat(*format, customPluginSearchPaths); + for (const auto& path : paths) + { + auto* pathInfo = new juce::DynamicObject(); + pathInfo->setProperty("format", formatName); + pathInfo->setProperty("path", path); + pathInfo->setProperty("exists", juce::File(path).isDirectory()); + pathInfo->setProperty("custom", containsNormalisedPath(customPluginSearchPaths, normalisePath(path))); + effectivePaths.add(juce::var(pathInfo)); + } + } + + juce::StringArray unsupportedFormats; + unsupportedFormats.add("VST2"); + unsupportedFormats.add("AAX"); + unsupportedFormats.add("32-bit plug-ins in this 64-bit build"); + unsupportedFormats.add("Standalone applications"); + #if !JUCE_MAC + unsupportedFormats.add("Audio Unit (AU/AUv3)"); + #endif + + auto* configuration = new juce::DynamicObject(); + configuration->setProperty("customPaths", makeStringArrayVar(customPluginSearchPaths)); + configuration->setProperty("blacklistedPlugins", makeStringArrayVar(blacklistedPlugins)); + configuration->setProperty("effectivePaths", juce::var(std::move(effectivePaths))); + configuration->setProperty("supportedFormats", makeStringArrayVar(supportedFormats)); + configuration->setProperty("unsupportedFormats", makeStringArrayVar(unsupportedFormats)); + configuration->setProperty( + "contentLibraryNote", + "Kontakt, Reaktor, and NKS sound libraries or presets load inside their host plug-in and are not scanned as separate plug-ins. S13FX/JSFX scripts use the separate OpenStudio Effects content library."); + return juce::var(configuration); +} + +bool PluginManager::addPluginSearchPath(const juce::String& directoryPath) +{ + const juce::ScopedLock managerLock(pluginManagerLock); + + const auto path = normalisePath(directoryPath); + if (path.isEmpty() || !juce::File(path).isDirectory()) + return false; + + if (containsNormalisedPath(customPluginSearchPaths, path)) + return true; + + customPluginSearchPaths.add(path); + if (savePluginSearchPaths()) + { + liveLv2PathsNeedPriming = true; + ++pluginStateRevision; + return true; + } + + customPluginSearchPaths.removeString(path, shouldIgnorePathCase()); + return false; +} + +bool PluginManager::removePluginSearchPath(const juce::String& directoryPath) +{ + const juce::ScopedLock managerLock(pluginManagerLock); + + const auto path = normalisePath(directoryPath); + const int index = customPluginSearchPaths.indexOf(path, shouldIgnorePathCase()); + if (index < 0) + return false; + + const auto removedPath = customPluginSearchPaths[index]; + customPluginSearchPaths.remove(index); + if (savePluginSearchPaths()) + { + liveLv2PathsNeedPriming = true; + ++pluginStateRevision; + return true; + } + + customPluginSearchPaths.insert(index, removedPath); + return false; +} + // ---- S13FX / JSFX scanning ---- juce::File PluginManager::getUserEffectsDirectory() @@ -345,22 +1364,30 @@ juce::File PluginManager::getStockEffectsDirectory() void PluginManager::scanForS13FX() { - s13fxList.clear(); + std::vector<S13FXInfo> discoveredEffects; // Scan stock effects (bundled with app) auto stockDir = getStockEffectsDirectory(); if (stockDir.isDirectory()) - scanDirectory(stockDir, true); + scanDirectory(stockDir, true, discoveredEffects); // Scan user effects auto userDir = getUserEffectsDirectory(); if (userDir.isDirectory()) - scanDirectory(userDir, false); + scanDirectory(userDir, false, discoveredEffects); + + const auto discoveredCount = discoveredEffects.size(); + { + const juce::ScopedLock managerLock(pluginManagerLock); + s13fxList = std::move(discoveredEffects); + } - juce::Logger::writeToLog("PluginManager: Found " + juce::String(s13fxList.size()) + " S13FX/JSFX scripts"); + juce::Logger::writeToLog("PluginManager: Found " + juce::String(discoveredCount) + " S13FX/JSFX scripts"); } -void PluginManager::scanDirectory(const juce::File& dir, bool isStock) +void PluginManager::scanDirectory(const juce::File& dir, + bool isStock, + std::vector<S13FXInfo>& destination) { juce::Array<juce::File> files; dir.findChildFiles(files, juce::File::findFiles, true, "*.jsfx;*.s13fx"); @@ -392,9 +1419,9 @@ void PluginManager::scanDirectory(const juce::File& dir, bool isStock) } } - s13fxList.push_back(std::move(info)); juce::Logger::writeToLog("PluginManager: Found S13FX: " + info.name + (isStock ? " (stock)" : " (user)")); + destination.push_back(std::move(info)); } } @@ -402,33 +1429,50 @@ void PluginManager::scanDirectory(const juce::File& dir, bool isStock) bool PluginManager::isPluginBlacklisted(const juce::String& pluginId) const { - return blacklistedPlugins.contains(pluginId); + const juce::ScopedLock managerLock(pluginManagerLock); + return containsPluginIdentifier(blacklistedPlugins, pluginId); } void PluginManager::blacklistPlugin(const juce::String& pluginId) { - if (!blacklistedPlugins.contains(pluginId)) + const juce::ScopedLock managerLock(pluginManagerLock); + const auto normalisedPluginId = normaliseCandidateIdentifier(pluginId); + if (normalisedPluginId.isNotEmpty() + && !containsPluginIdentifier(blacklistedPlugins, normalisedPluginId)) { - blacklistedPlugins.add(pluginId); + blacklistedPlugins.add(normalisedPluginId); blacklistFile.getParentDirectory().createDirectory(); blacklistFile.replaceWithText(blacklistedPlugins.joinIntoString("\n")); - juce::Logger::writeToLog("PluginManager: Blacklisted plugin: " + pluginId); + ++pluginStateRevision; + juce::Logger::writeToLog("PluginManager: Blacklisted plugin: " + normalisedPluginId); } } -void PluginManager::removeFromBlacklist(const juce::String& pluginId) +bool PluginManager::removeFromBlacklist(const juce::String& pluginId) { - int idx = blacklistedPlugins.indexOf(pluginId); + const juce::ScopedLock managerLock(pluginManagerLock); + const auto normalisedPluginId = normaliseCandidateIdentifier(pluginId); + int idx = indexOfPluginIdentifier(blacklistedPlugins, normalisedPluginId); if (idx >= 0) { + const auto removedPluginId = blacklistedPlugins[idx]; blacklistedPlugins.remove(idx); - blacklistFile.replaceWithText(blacklistedPlugins.joinIntoString("\n")); - juce::Logger::writeToLog("PluginManager: Removed from blacklist: " + pluginId); + if (blacklistFile.replaceWithText(blacklistedPlugins.joinIntoString("\n"))) + { + ++pluginStateRevision; + juce::Logger::writeToLog("PluginManager: Removed from blacklist for retry: " + pluginId); + return true; + } + + blacklistedPlugins.insert(idx, removedPluginId); } + + return false; } juce::StringArray PluginManager::getBlacklistedPlugins() const { + const juce::ScopedLock managerLock(pluginManagerLock); return blacklistedPlugins; } @@ -443,6 +1487,7 @@ bool PluginManager::isARAPlugin(const juce::PluginDescription& description) cons juce::Array<juce::PluginDescription> PluginManager::getARAPlugins() const { + const juce::ScopedLock managerLock(pluginManagerLock); juce::Array<juce::PluginDescription> araPlugins; for (const auto& desc : knownPluginList.getTypes()) { diff --git a/Source/PluginManager.h b/Source/PluginManager.h index 2341361..3d98376 100644 --- a/Source/PluginManager.h +++ b/Source/PluginManager.h @@ -14,21 +14,28 @@ struct S13FXInfo bool isStock = false; // true = shipped with app (read-only) }; -// Manages VST3 plugin scanning and S13FX/JSFX script discovery +// Manages external plugin scanning and S13FX/JSFX script discovery class PluginManager { public: PluginManager(); ~PluginManager(); - // Scan for available plugins (VST3 + S13FX/JSFX) - void scanForPlugins(); + // Scan for available external plugins. The returned object contains a + // per-format discovery report suitable for presenting in the UI. + juce::var scanForPlugins(bool forceRescan = false); - // Get list of available VST3 plugins + // Persistent folders supplied by the user are searched by every supported + // external plugin format, in addition to that format's standard locations. + juce::var getPluginScanConfiguration() const; + bool addPluginSearchPath(const juce::String& directoryPath); + bool removePluginSearchPath(const juce::String& directoryPath); + + // Get all discovered external plugins juce::Array<juce::PluginDescription> getAvailablePlugins() const; // Get list of available S13FX/JSFX scripts - const std::vector<S13FXInfo>& getAvailableS13FX() const { return s13fxList; } + std::vector<S13FXInfo> getAvailableS13FX() const; // Scan for S13FX/JSFX scripts only void scanForS13FX(); @@ -54,7 +61,7 @@ class PluginManager // Plugin crash isolation: check if a plugin previously crashed bool isPluginBlacklisted(const juce::String& pluginId) const; void blacklistPlugin(const juce::String& pluginId); - void removeFromBlacklist(const juce::String& pluginId); + bool removeFromBlacklist(const juce::String& pluginId); juce::StringArray getBlacklistedPlugins() const; private: @@ -62,12 +69,23 @@ class PluginManager juce::KnownPluginList knownPluginList; juce::File pluginListFile; juce::File blacklistFile; + juce::File pluginSearchPathsFile; + juce::File pluginScanDeadMansPedalFile; juce::StringArray blacklistedPlugins; + juce::StringArray customPluginSearchPaths; std::vector<S13FXInfo> s13fxList; + mutable juce::CriticalSection pluginManagerLock; + juce::CriticalSection pluginScanLock; + bool liveLv2PathsNeedPriming = true; + juce::uint64 pluginStateRevision = 0; - void savePluginList(); + bool savePluginList(); void loadPluginList(); - void scanDirectory(const juce::File& dir, bool isStock); + bool savePluginSearchPaths() const; + void loadPluginSearchPaths(); + static void scanDirectory(const juce::File& dir, + bool isStock, + std::vector<S13FXInfo>& destination); JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PluginManager) }; diff --git a/Source/PluginWindowManager.cpp b/Source/PluginWindowManager.cpp index 55b10e2..512b68a 100644 --- a/Source/PluginWindowManager.cpp +++ b/Source/PluginWindowManager.cpp @@ -36,6 +36,51 @@ std::optional<PluginWindowManager::PluginEditorTarget::Scope> stringToScope(cons return std::nullopt; } +juce::String functionKeyForDom(int keyCode) +{ + if (keyCode == juce::KeyPress::F1Key) return "F1"; + if (keyCode == juce::KeyPress::F2Key) return "F2"; + if (keyCode == juce::KeyPress::F3Key) return "F3"; + if (keyCode == juce::KeyPress::F4Key) return "F4"; + if (keyCode == juce::KeyPress::F5Key) return "F5"; + if (keyCode == juce::KeyPress::F6Key) return "F6"; + if (keyCode == juce::KeyPress::F7Key) return "F7"; + if (keyCode == juce::KeyPress::F8Key) return "F8"; + if (keyCode == juce::KeyPress::F9Key) return "F9"; + if (keyCode == juce::KeyPress::F10Key) return "F10"; + if (keyCode == juce::KeyPress::F11Key) return "F11"; + if (keyCode == juce::KeyPress::F12Key) return "F12"; + return {}; +} + +juce::String numberPadKeyForDom(int keyCode, bool code) +{ + const auto digit = [keyCode, code](int candidate, const char* value, const char* domCode) + { + return keyCode == candidate ? juce::String(code ? domCode : value) : juce::String(); + }; + + if (auto value = digit(juce::KeyPress::numberPad0, "0", "Numpad0"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPad1, "1", "Numpad1"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPad2, "2", "Numpad2"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPad3, "3", "Numpad3"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPad4, "4", "Numpad4"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPad5, "5", "Numpad5"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPad6, "6", "Numpad6"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPad7, "7", "Numpad7"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPad8, "8", "Numpad8"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPad9, "9", "Numpad9"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPadAdd, "+", "NumpadAdd"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPadSubtract, "-", "NumpadSubtract"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPadMultiply, "*", "NumpadMultiply"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPadDivide, "/", "NumpadDivide"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPadSeparator, ",", "NumpadComma"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPadDecimalPoint, ".", "NumpadDecimal"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPadEquals, "=", "NumpadEqual"); value.isNotEmpty()) return value; + if (auto value = digit(juce::KeyPress::numberPadDelete, "Delete", "NumpadDecimal"); value.isNotEmpty()) return value; + return {}; +} + juce::String normaliseKeyForDom(const juce::KeyPress& key) { const auto keyCode = key.getKeyCode(); @@ -53,15 +98,21 @@ juce::String normaliseKeyForDom(const juce::KeyPress& key) if (keyCode == juce::KeyPress::spaceKey) return " "; if (keyCode == juce::KeyPress::returnKey) return "Enter"; + if (keyCode == juce::KeyPress::tabKey) return "Tab"; if (keyCode == juce::KeyPress::escapeKey) return "Escape"; - if (keyCode == juce::KeyPress::deleteKey || keyCode == juce::KeyPress::backspaceKey) return "Delete"; + if (keyCode == juce::KeyPress::deleteKey) return "Delete"; + if (keyCode == juce::KeyPress::backspaceKey) return "Backspace"; if (keyCode == juce::KeyPress::leftKey) return "ArrowLeft"; if (keyCode == juce::KeyPress::rightKey) return "ArrowRight"; if (keyCode == juce::KeyPress::upKey) return "ArrowUp"; if (keyCode == juce::KeyPress::downKey) return "ArrowDown"; if (keyCode == juce::KeyPress::insertKey) return "Insert"; - if (keyCode == juce::KeyPress::F1Key) return "F1"; - if (keyCode == juce::KeyPress::F2Key) return "F2"; + if (keyCode == juce::KeyPress::pageUpKey) return "PageUp"; + if (keyCode == juce::KeyPress::pageDownKey) return "PageDown"; + if (keyCode == juce::KeyPress::homeKey) return "Home"; + if (keyCode == juce::KeyPress::endKey) return "End"; + if (const auto functionKey = functionKeyForDom(keyCode); functionKey.isNotEmpty()) return functionKey; + if (const auto numberPadKey = numberPadKeyForDom(keyCode, false); numberPadKey.isNotEmpty()) return numberPadKey; if (keyCode == ',') return ","; if (const auto textChar = key.getTextCharacter(); textChar != 0) @@ -85,15 +136,21 @@ juce::String normaliseCodeForDom(const juce::KeyPress& key) if (keyCode == juce::KeyPress::spaceKey) return "Space"; if (keyCode == juce::KeyPress::returnKey) return "Enter"; + if (keyCode == juce::KeyPress::tabKey) return "Tab"; if (keyCode == juce::KeyPress::escapeKey) return "Escape"; - if (keyCode == juce::KeyPress::deleteKey || keyCode == juce::KeyPress::backspaceKey) return "Delete"; + if (keyCode == juce::KeyPress::deleteKey) return "Delete"; + if (keyCode == juce::KeyPress::backspaceKey) return "Backspace"; if (keyCode == juce::KeyPress::leftKey) return "ArrowLeft"; if (keyCode == juce::KeyPress::rightKey) return "ArrowRight"; if (keyCode == juce::KeyPress::upKey) return "ArrowUp"; if (keyCode == juce::KeyPress::downKey) return "ArrowDown"; if (keyCode == juce::KeyPress::insertKey) return "Insert"; - if (keyCode == juce::KeyPress::F1Key) return "F1"; - if (keyCode == juce::KeyPress::F2Key) return "F2"; + if (keyCode == juce::KeyPress::pageUpKey) return "PageUp"; + if (keyCode == juce::KeyPress::pageDownKey) return "PageDown"; + if (keyCode == juce::KeyPress::homeKey) return "Home"; + if (keyCode == juce::KeyPress::endKey) return "End"; + if (const auto functionKey = functionKeyForDom(keyCode); functionKey.isNotEmpty()) return functionKey; + if (const auto numberPadKey = numberPadKeyForDom(keyCode, true); numberPadKey.isNotEmpty()) return numberPadKey; if (keyCode == ',') return "Comma"; return {}; @@ -106,10 +163,15 @@ juce::var keyPressToVar(const juce::KeyPress& key) obj->setProperty("key", normaliseKeyForDom(key)); obj->setProperty("code", normaliseCodeForDom(key)); - obj->setProperty("ctrlKey", modifiers.isCtrlDown() || modifiers.isCommandDown()); + #if JUCE_MAC + obj->setProperty("ctrlKey", modifiers.isCtrlDown()); + obj->setProperty("metaKey", modifiers.isCommandDown()); + #else + obj->setProperty("ctrlKey", modifiers.isCtrlDown()); + obj->setProperty("metaKey", false); + #endif obj->setProperty("shiftKey", modifiers.isShiftDown()); obj->setProperty("altKey", modifiers.isAltDown()); - obj->setProperty("metaKey", modifiers.isCommandDown() && !modifiers.isCtrlDown()); obj->setProperty("repeat", false); obj->setProperty("source", "pluginWindow"); return juce::var(obj); @@ -178,7 +240,7 @@ PluginWindowManager::PluginWindow::PluginWindow(PluginWindowManager& ownerIn, setUsingNativeTitleBar(true); setResizable(true, false); - if (auto* editor = processor.createEditor()) + if (auto* editor = processor.createEditorAndMakeActive()) { setContentOwned(editor, true); @@ -461,7 +523,8 @@ bool PluginWindowManager::shouldSuppressDuplicateForward(const juce::KeyPress& k void PluginWindowManager::positionWindow(PluginWindow& window) const { auto bounds = window.getBounds(); - auto displayArea = juce::Desktop::getInstance().getDisplays().getPrimaryDisplay()->userArea; + auto displayArea = juce::Desktop::getInstance().getDisplays().getPrimaryDisplay()->userBounds + .getSmallestIntegerContainer(); if (mainWindowComponent != nullptr) { @@ -470,7 +533,7 @@ void PluginWindowManager::positionWindow(PluginWindow& window) const : mainWindowComponent->getScreenBounds(); if (auto* display = juce::Desktop::getInstance().getDisplays().getDisplayForRect(ownerBounds)) - displayArea = display->userArea; + displayArea = display->userBounds.getSmallestIntegerContainer(); } bounds.setSize(juce::jmin(bounds.getWidth(), displayArea.getWidth() - 16), diff --git a/Source/PolyResynthesizer.h b/Source/PolyResynthesizer.h index f81a1be..7b5db54 100644 --- a/Source/PolyResynthesizer.h +++ b/Source/PolyResynthesizer.h @@ -9,8 +9,8 @@ * * The old SMS-based polyphonic resynthesis has been removed. * This stub provides the API surface that AudioEngine uses so it compiles. - * Polyphonic pitch correction will be re-implemented as a future improvement - * (per pitch_corrector_feat_plan.md). + * Polyphonic note detection and MIDI extraction remain supported; the current + * audio-resynthesis limitation is documented in docs/implemented_features.md. */ class PolyResynthesizer { diff --git a/Source/S13FXGfxEditor.cpp b/Source/S13FXGfxEditor.cpp index 9129191..ce0f5a6 100644 --- a/Source/S13FXGfxEditor.cpp +++ b/Source/S13FXGfxEditor.cpp @@ -150,7 +150,7 @@ void S13FXGfxEditor::timerCallback() if (ysfx_gfx_wants_retina(effect)) { auto* display = juce::Desktop::getInstance().getDisplays() - .getDisplayForPoint(getScreenPosition()); + .getDisplayForPoint(getScreenPosition().toFloat()); if (display) scaleFactor = static_cast<float>(display->scale); } diff --git a/Source/S13PluginEditors.cpp b/Source/S13PluginEditors.cpp index bbbaa21..e4c3c22 100644 --- a/Source/S13PluginEditors.cpp +++ b/Source/S13PluginEditors.cpp @@ -2087,7 +2087,6 @@ S13ReverbEditor::S13ReverbEditor(S13Reverb& p) algorithmBox.addItem("Hall", 2); algorithmBox.addItem("Plate", 3); algorithmBox.addItem("Chamber", 4); - algorithmBox.addItem("Shimmer", 5); algorithmBox.onChange = [this]() { proc.algorithm = static_cast<float>(algorithmBox.getSelectedId() - 1); }; addAndMakeVisible(freezeBtn); diff --git a/Source/S13ScriptWindow.cpp b/Source/S13ScriptWindow.cpp index 46b21f7..cb53b43 100644 --- a/Source/S13ScriptWindow.cpp +++ b/Source/S13ScriptWindow.cpp @@ -196,7 +196,7 @@ void S13ScriptWindow::drawString(const juce::String& text, int drawFlags) fbGraphics->setFont(currentFont); fbGraphics->drawText(text, drawX, drawY, 1000, static_cast<int>(currentFont.getHeight()) + 2, juce::Justification::topLeft, false); - drawX += static_cast<int>(currentFont.getStringWidthFloat(text)); + drawX += juce::GlyphArrangement::getStringWidthInt(currentFont, text); } void S13ScriptWindow::setFont(int size, const juce::String& face, int style) @@ -213,7 +213,7 @@ void S13ScriptWindow::setFont(int size, const juce::String& face, int style) std::pair<int, int> S13ScriptWindow::measureString(const juce::String& text) const { - int w = static_cast<int>(currentFont.getStringWidthFloat(text)); + int w = juce::GlyphArrangement::getStringWidthInt(currentFont, text); int h = static_cast<int>(currentFont.getHeight()); return { w, h }; } diff --git a/Source/ScriptEngine.cpp b/Source/ScriptEngine.cpp index a9751ea..bef23c2 100644 --- a/Source/ScriptEngine.cpp +++ b/Source/ScriptEngine.cpp @@ -795,7 +795,7 @@ static int l_renderProject(lua_State* L) } // --- File dialog (returns a temp file path for script I/O) --- -// JUCE 8 removed synchronous file dialogs; Lua scripts run on message thread +// Current JUCE APIs use asynchronous file dialogs; Lua scripts run on the message thread. // so we can't use async+WaitableEvent without deadlocking. Scripts should use // explicit file paths passed as arguments instead. static int l_fileDialog(lua_State* L) diff --git a/Source/TimecodeSync.cpp b/Source/TimecodeSync.cpp index 3264296..6d9321c 100644 --- a/Source/TimecodeSync.cpp +++ b/Source/TimecodeSync.cpp @@ -1,9 +1,223 @@ #include "TimecodeSync.h" +#include <array> +#include <cstdint> + +static_assert(std::atomic<SMPTEFrameRate>::is_always_lock_free); + +//============================================================================== +// Realtime MIDI output handoff +//============================================================================== + +class TimecodeMIDIOutputDispatcher final : private juce::Thread +{ +public: + TimecodeMIDIOutputDispatcher() + : juce::Thread("OpenStudio Timecode MIDI Sender") + { + } + + ~TimecodeMIDIOutputDispatcher() override + { + realtimeEnabled.store(false, std::memory_order_release); + connected.store(false, std::memory_order_release); + generation.fetch_add(1, std::memory_order_acq_rel); + signalThreadShouldExit(); + stopThread(2000); + + const juce::ScopedLock sl(outputLock); + output.reset(); + } + + bool connect(const juce::String& midiOutputName) + { + disconnect(); + + std::unique_ptr<juce::MidiOutput> newOutput; + for (const auto& device : juce::MidiOutput::getAvailableDevices()) + { + if (device.name == midiOutputName) + { + newOutput = juce::MidiOutput::openDevice(device.identifier); + break; + } + } + + if (newOutput == nullptr) + return false; + + { + const juce::ScopedLock sl(outputLock); + output = std::move(newOutput); + generation.fetch_add(1, std::memory_order_acq_rel); + connected.store(true, std::memory_order_release); + } + + if (!isThreadRunning() + && !startThread(juce::Thread::Priority::normal)) + { + disconnect(); + return false; + } + return true; + } + + void disconnect() + { + connected.store(false, std::memory_order_release); + generation.fetch_add(1, std::memory_order_acq_rel); + notify(); + + const juce::ScopedLock sl(outputLock); + output.reset(); + } + + bool isConnected() const noexcept + { + return connected.load(std::memory_order_acquire); + } + + void setRealtimeEnabled(bool shouldEnable) noexcept + { + if (shouldEnable) + { + generation.fetch_add(1, std::memory_order_acq_rel); + realtimeEnabled.store(true, std::memory_order_release); + } + else + { + realtimeEnabled.store(false, std::memory_order_release); + generation.fetch_add(1, std::memory_order_acq_rel); + } + notify(); + } + + bool enqueueRealtimeByte(std::uint8_t byte) noexcept + { + return enqueueRealtimeMessage(&byte, 1); + } + + bool enqueueRealtimeMessage(const std::uint8_t* bytes, int size) noexcept + { + if (!realtimeEnabled.load(std::memory_order_acquire) + || !connected.load(std::memory_order_acquire) + || bytes == nullptr + || size <= 0 + || size > kMaxRealtimeMessageBytes) + { + return false; + } + + const auto write = writePosition.load(std::memory_order_relaxed); + const auto read = readPosition.load(std::memory_order_acquire); + if (write - read >= kQueueCapacity) + { + droppedMessageCount.fetch_add(1, std::memory_order_relaxed); + return false; + } + + auto& packet = queue[write & (kQueueCapacity - 1)]; + packet.generation = generation.load(std::memory_order_acquire); + packet.size = static_cast<std::uint8_t>(size); + for (int index = 0; index < size; ++index) + packet.bytes[static_cast<std::size_t>(index)] = bytes[index]; + + writePosition.store(write + 1, std::memory_order_release); + return true; + } + + void sendControlMessage(const std::uint8_t* bytes, int size) + { + if (bytes == nullptr || size <= 0) + return; + + const juce::ScopedLock sl(outputLock); + if (output != nullptr && connected.load(std::memory_order_acquire)) + output->sendMessageNow(juce::MidiMessage(bytes, size)); + } + +private: + struct Packet + { + std::array<std::uint8_t, 3> bytes {}; + std::uint32_t generation = 0; + std::uint8_t size = 0; + }; + + static constexpr std::uint32_t kQueueCapacity = 1024; + static constexpr int kMaxRealtimeMessageBytes = 3; + static_assert((kQueueCapacity & (kQueueCapacity - 1)) == 0); + static_assert(std::atomic<bool>::is_always_lock_free); + static_assert(std::atomic<std::uint32_t>::is_always_lock_free); + + bool dequeue(Packet& packet) noexcept + { + const auto read = readPosition.load(std::memory_order_relaxed); + if (read == writePosition.load(std::memory_order_acquire)) + return false; + + packet = queue[read & (kQueueCapacity - 1)]; + readPosition.store(read + 1, std::memory_order_release); + return true; + } + + void run() override + { + while (!threadShouldExit()) + { + bool consumedPacket = false; + Packet packet; + while (dequeue(packet)) + { + consumedPacket = true; + const auto currentGeneration = generation.load(std::memory_order_acquire); + if (packet.generation != currentGeneration + || !connected.load(std::memory_order_acquire) + || !realtimeEnabled.load(std::memory_order_acquire)) + { + continue; + } + + const juce::ScopedLock sl(outputLock); + if (output != nullptr + && connected.load(std::memory_order_acquire) + && realtimeEnabled.load(std::memory_order_acquire) + && packet.generation == generation.load(std::memory_order_acquire)) + { + output->sendMessageNow( + juce::MidiMessage(packet.bytes.data(), static_cast<int>(packet.size))); + } + } + + if (!consumedPacket) + { + const bool active = connected.load(std::memory_order_relaxed) + && realtimeEnabled.load(std::memory_order_relaxed); + wait(active ? 1 : 20); + } + } + } + + std::array<Packet, kQueueCapacity> queue {}; + std::atomic<std::uint32_t> writePosition { 0 }; + std::atomic<std::uint32_t> readPosition { 0 }; + std::atomic<std::uint32_t> generation { 1 }; + std::atomic<std::uint32_t> droppedMessageCount { 0 }; + std::atomic<bool> connected { false }; + std::atomic<bool> realtimeEnabled { false }; + juce::CriticalSection outputLock; + std::unique_ptr<juce::MidiOutput> output; +}; + //============================================================================== // MIDIClockOutput //============================================================================== +MIDIClockOutput::MIDIClockOutput() + : outputDispatcher(std::make_unique<TimecodeMIDIOutputDispatcher>()) +{ +} + MIDIClockOutput::~MIDIClockOutput() { disconnect(); @@ -12,33 +226,46 @@ MIDIClockOutput::~MIDIClockOutput() bool MIDIClockOutput::connect(const juce::String& midiOutputName) { disconnect(); - auto devices = juce::MidiOutput::getAvailableDevices(); - for (const auto& d : devices) - { - if (d.name == midiOutputName) - { - output = juce::MidiOutput::openDevice(d.identifier); - if (output) - juce::Logger::writeToLog("MIDIClockOutput: Connected to " + midiOutputName); - break; - } - } - return output != nullptr; + const bool connected = outputDispatcher->connect(midiOutputName); + outputDispatcher->setRealtimeEnabled(isEnabled.load(std::memory_order_acquire)); + resetClockAccumulatorRequested.store(true, std::memory_order_release); + if (connected) + juce::Logger::writeToLog("MIDIClockOutput: Connected to " + midiOutputName); + return connected; } void MIDIClockOutput::disconnect() { - if (output) - { + const bool wasConnected = outputDispatcher->isConnected(); + outputDispatcher->setRealtimeEnabled(false); + if (wasConnected) sendStop(); - output.reset(); - } - clockAccumulator = 0.0; + outputDispatcher->disconnect(); + resetClockAccumulatorRequested.store(true, std::memory_order_release); +} + +bool MIDIClockOutput::isConnected() const noexcept +{ + return outputDispatcher->isConnected(); +} + +void MIDIClockOutput::setEnabled(bool enabled) noexcept +{ + isEnabled.store(enabled, std::memory_order_release); + outputDispatcher->setRealtimeEnabled(enabled); + if (!enabled) + resetClockAccumulatorRequested.store(true, std::memory_order_release); } void MIDIClockOutput::processBlock(int numSamples, double sampleRate, double bpm, bool playing) { - if (!output || !isEnabled || !playing || bpm <= 0.0 || sampleRate <= 0.0) + if (!isEnabled.load(std::memory_order_relaxed)) + return; + + if (resetClockAccumulatorRequested.exchange(false, std::memory_order_acq_rel)) + clockAccumulator = 0.0; + + if (!playing || bpm <= 0.0 || sampleRate <= 0.0 || !outputDispatcher->isConnected()) return; // MIDI Clock: 24 pulses per quarter note @@ -48,30 +275,37 @@ void MIDIClockOutput::processBlock(int numSamples, double sampleRate, double bpm while (clockAccumulator >= samplesPerClock) { - output->sendMessageNow(juce::MidiMessage(0xF8)); // Timing Clock + outputDispatcher->enqueueRealtimeByte(0xF8); // Timing Clock clockAccumulator -= samplesPerClock; } } void MIDIClockOutput::sendStart() { - if (output && isEnabled) + if (isEnabled.load(std::memory_order_acquire) && outputDispatcher->isConnected()) { - clockAccumulator = 0.0; - output->sendMessageNow(juce::MidiMessage(0xFA)); // Start + resetClockAccumulatorRequested.store(true, std::memory_order_release); + const std::uint8_t start = 0xFA; + outputDispatcher->sendControlMessage(&start, 1); } } void MIDIClockOutput::sendStop() { - if (output && isEnabled) - output->sendMessageNow(juce::MidiMessage(0xFC)); // Stop + if (isEnabled.load(std::memory_order_acquire) && outputDispatcher->isConnected()) + { + const std::uint8_t stop = 0xFC; + outputDispatcher->sendControlMessage(&stop, 1); + } } void MIDIClockOutput::sendContinue() { - if (output && isEnabled) - output->sendMessageNow(juce::MidiMessage(0xFB)); // Continue + if (isEnabled.load(std::memory_order_acquire) && outputDispatcher->isConnected()) + { + const std::uint8_t resume = 0xFB; + outputDispatcher->sendControlMessage(&resume, 1); + } } //============================================================================== @@ -174,6 +408,11 @@ void MIDIClockInput::handleIncomingMidiMessage(juce::MidiInput* source, const ju // MTCGenerator //============================================================================== +MTCGenerator::MTCGenerator() + : outputDispatcher(std::make_unique<TimecodeMIDIOutputDispatcher>()) +{ +} + MTCGenerator::~MTCGenerator() { disconnect(); @@ -182,30 +421,43 @@ MTCGenerator::~MTCGenerator() bool MTCGenerator::connect(const juce::String& midiOutputName) { disconnect(); - auto devices = juce::MidiOutput::getAvailableDevices(); - for (const auto& d : devices) - { - if (d.name == midiOutputName) - { - output = juce::MidiOutput::openDevice(d.identifier); - if (output) - juce::Logger::writeToLog("MTCGenerator: Connected to " + midiOutputName); - break; - } - } - return output != nullptr; + const bool connected = outputDispatcher->connect(midiOutputName); + outputDispatcher->setRealtimeEnabled(isEnabled.load(std::memory_order_acquire)); + resetGeneratorStateRequested.store(true, std::memory_order_release); + if (connected) + juce::Logger::writeToLog("MTCGenerator: Connected to " + midiOutputName); + return connected; } void MTCGenerator::disconnect() { - output.reset(); - qfCounter = 0; - qfAccumulator = 0.0; + outputDispatcher->setRealtimeEnabled(false); + outputDispatcher->disconnect(); + resetGeneratorStateRequested.store(true, std::memory_order_release); +} + +bool MTCGenerator::isConnected() const noexcept +{ + return outputDispatcher->isConnected(); +} + +void MTCGenerator::setEnabled(bool enabled) noexcept +{ + isEnabled.store(enabled, std::memory_order_release); + outputDispatcher->setRealtimeEnabled(enabled); + if (!enabled) + resetGeneratorStateRequested.store(true, std::memory_order_release); +} + +void MTCGenerator::setFrameRate(SMPTEFrameRate rate) noexcept +{ + frameRate.store(rate, std::memory_order_release); + resetGeneratorStateRequested.store(true, std::memory_order_release); } -double MTCGenerator::getActualFrameRate() const +double MTCGenerator::getActualFrameRate(SMPTEFrameRate rate) noexcept { - switch (frameRate) + switch (rate) { case SMPTEFrameRate::fps24: return 24.0; case SMPTEFrameRate::fps25: return 25.0; @@ -215,15 +467,15 @@ double MTCGenerator::getActualFrameRate() const return 25.0; } -MTCGenerator::SMPTETime MTCGenerator::positionToSMPTE(double seconds) const +MTCGenerator::SMPTETime MTCGenerator::positionToSMPTE(double seconds, SMPTEFrameRate rate) { SMPTETime t; - double fps = getActualFrameRate(); + const double fps = getActualFrameRate(rate); int totalFrames = (int)(seconds * fps); // Drop frame compensation for 29.97 - if (frameRate == SMPTEFrameRate::fps2997df) + if (rate == SMPTEFrameRate::fps2997df) { // Drop frame: skip frame 0 and 1 at the start of each minute // except every 10th minute @@ -256,20 +508,30 @@ MTCGenerator::SMPTETime MTCGenerator::positionToSMPTE(double seconds) const void MTCGenerator::processBlock(int numSamples, double sampleRate, double positionSeconds, bool playing) { - if (!output || !isEnabled || !playing || sampleRate <= 0.0) + if (!isEnabled.load(std::memory_order_relaxed)) + return; + + if (resetGeneratorStateRequested.exchange(false, std::memory_order_acq_rel)) + { + qfCounter = 0; + qfAccumulator = 0.0; + } + + if (!playing || sampleRate <= 0.0 || !outputDispatcher->isConnected()) return; // MTC quarter-frame rate: 2 per frame × fps / 4 = fps/2 quarter-frames per second // But the standard says: 4 quarter-frames per frame, so 4 * fps QF per second // Each QF is sent at fps * 4 rate (e.g., at 25fps = 100 QF/sec) - double fps = getActualFrameRate(); - double samplesPerQF = sampleRate / (fps * 4.0); + const auto currentFrameRate = frameRate.load(std::memory_order_acquire); + const double fps = getActualFrameRate(currentFrameRate); + const double samplesPerQF = sampleRate / (fps * 4.0); qfAccumulator += numSamples; while (qfAccumulator >= samplesPerQF) { - SMPTETime t = positionToSMPTE(positionSeconds); + const SMPTETime t = positionToSMPTE(positionSeconds, currentFrameRate); int data = 0; switch (qfCounter) @@ -281,11 +543,16 @@ void MTCGenerator::processBlock(int numSamples, double sampleRate, double positi case 4: data = (0x40) | (t.minutes & 0x0F); break; case 5: data = (0x50) | ((t.minutes >> 4) & 0x03); break; case 6: data = (0x60) | (t.hours & 0x0F); break; - case 7: data = (0x70) | ((t.hours >> 4) & 0x01) | ((int)frameRate << 1); break; + case 7: data = (0x70) | ((t.hours >> 4) & 0x01) + | (static_cast<int>(currentFrameRate) << 1); break; } // Quarter-frame message: F1 <data> - output->sendMessageNow(juce::MidiMessage(0xF1, data)); + const std::uint8_t message[2] { + 0xF1, + static_cast<std::uint8_t>(data) + }; + outputDispatcher->enqueueRealtimeMessage(message, 2); qfCounter = (qfCounter + 1) & 7; qfAccumulator -= samplesPerQF; @@ -294,24 +561,27 @@ void MTCGenerator::processBlock(int numSamples, double sampleRate, double positi void MTCGenerator::sendFullFrame(double positionSeconds) { - if (!output || !isEnabled) return; + if (!isEnabled.load(std::memory_order_acquire) || !outputDispatcher->isConnected()) + return; - SMPTETime t = positionToSMPTE(positionSeconds); + const auto currentFrameRate = frameRate.load(std::memory_order_acquire); + const SMPTETime t = positionToSMPTE(positionSeconds, currentFrameRate); // Full frame SysEx: F0 7F 7F 01 01 hr mn sc fr F7 - uint8_t sysex[10]; + std::uint8_t sysex[10]; sysex[0] = 0xF0; sysex[1] = 0x7F; // Universal real-time sysex[2] = 0x7F; // All devices sysex[3] = 0x01; // MTC sysex[4] = 0x01; // Full frame - sysex[5] = (uint8_t)(((int)frameRate << 5) | (t.hours & 0x1F)); - sysex[6] = (uint8_t)(t.minutes & 0x3F); - sysex[7] = (uint8_t)(t.seconds & 0x3F); - sysex[8] = (uint8_t)(t.frames & 0x1F); + sysex[5] = static_cast<std::uint8_t>( + (static_cast<int>(currentFrameRate) << 5) | (t.hours & 0x1F)); + sysex[6] = static_cast<std::uint8_t>(t.minutes & 0x3F); + sysex[7] = static_cast<std::uint8_t>(t.seconds & 0x3F); + sysex[8] = static_cast<std::uint8_t>(t.frames & 0x1F); sysex[9] = 0xF7; - output->sendMessageNow(juce::MidiMessage(sysex, 10)); + outputDispatcher->sendControlMessage(sysex, 10); } //============================================================================== diff --git a/Source/TimecodeSync.h b/Source/TimecodeSync.h index d531989..388e4f6 100644 --- a/Source/TimecodeSync.h +++ b/Source/TimecodeSync.h @@ -4,6 +4,8 @@ #include <atomic> #include <memory> +class TimecodeMIDIOutputDispatcher; + //============================================================================== // SMPTE Frame Rates //============================================================================== @@ -23,27 +25,29 @@ enum class SMPTEFrameRate class MIDIClockOutput { public: - MIDIClockOutput() = default; + MIDIClockOutput(); ~MIDIClockOutput(); bool connect(const juce::String& midiOutputName); void disconnect(); - bool isConnected() const { return output != nullptr; } + bool isConnected() const noexcept; - void setEnabled(bool enabled) { isEnabled = enabled; } - bool getEnabled() const { return isEnabled; } + void setEnabled(bool enabled) noexcept; + bool getEnabled() const noexcept { return isEnabled.load(std::memory_order_acquire); } // Call from audio callback void processBlock(int numSamples, double sampleRate, double bpm, bool playing); - // Call on transport start/stop/continue + // Call from a non-audio transport/control thread. These messages may block + // in the operating-system MIDI driver, but never contend with processBlock(). void sendStart(); void sendStop(); void sendContinue(); private: - std::unique_ptr<juce::MidiOutput> output; + std::unique_ptr<TimecodeMIDIOutputDispatcher> outputDispatcher; std::atomic<bool> isEnabled { false }; + std::atomic<bool> resetClockAccumulatorRequested { false }; double clockAccumulator = 0.0; // Fractional clock tick accumulator JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MIDIClockOutput) @@ -103,36 +107,38 @@ class MIDIClockInput : public juce::MidiInputCallback class MTCGenerator { public: - MTCGenerator() = default; + MTCGenerator(); ~MTCGenerator(); bool connect(const juce::String& midiOutputName); void disconnect(); - bool isConnected() const { return output != nullptr; } + bool isConnected() const noexcept; - void setEnabled(bool enabled) { isEnabled = enabled; } - bool getEnabled() const { return isEnabled; } + void setEnabled(bool enabled) noexcept; + bool getEnabled() const noexcept { return isEnabled.load(std::memory_order_acquire); } - void setFrameRate(SMPTEFrameRate rate) { frameRate = rate; } - SMPTEFrameRate getFrameRate() const { return frameRate; } + void setFrameRate(SMPTEFrameRate rate) noexcept; + SMPTEFrameRate getFrameRate() const noexcept { return frameRate.load(std::memory_order_acquire); } // Call from audio callback to send quarter-frame messages void processBlock(int numSamples, double sampleRate, double positionSeconds, bool playing); - // Send a full-frame MTC message (for locate/scrub) + // Send a full-frame MTC message from a non-audio control thread + // (for locate/scrub). void sendFullFrame(double positionSeconds); private: - std::unique_ptr<juce::MidiOutput> output; + std::unique_ptr<TimecodeMIDIOutputDispatcher> outputDispatcher; std::atomic<bool> isEnabled { false }; - SMPTEFrameRate frameRate = SMPTEFrameRate::fps25; + std::atomic<SMPTEFrameRate> frameRate { SMPTEFrameRate::fps25 }; + std::atomic<bool> resetGeneratorStateRequested { false }; int qfCounter = 0; // Quarter-frame counter (0-7) double qfAccumulator = 0.0; struct SMPTETime { int hours; int minutes; int seconds; int frames; }; - SMPTETime positionToSMPTE(double seconds) const; - double getActualFrameRate() const; + static SMPTETime positionToSMPTE(double seconds, SMPTEFrameRate rate); + static double getActualFrameRate(SMPTEFrameRate rate) noexcept; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MTCGenerator) }; diff --git a/Source/TrackProcessor.cpp b/Source/TrackProcessor.cpp index 778891d..6a09f43 100644 --- a/Source/TrackProcessor.cpp +++ b/Source/TrackProcessor.cpp @@ -1,11 +1,384 @@ #include "TrackProcessor.h" +#include "BuiltInParameterSupport.h" +#include "BuiltInEffects2.h" #include <algorithm> +#include <cmath> +#include <cstdint> +#include <limits> // Maximum channel count for the pre-allocated FX processing buffer. // Must be large enough for multi-output instruments (e.g. Komplete Kontrol = 32 out). static constexpr int kMaxFXChannels = 64; static constexpr int kMinimumHostedPluginBlockSize = 512; +static constexpr int kHostBypassLatencyHeadroomSamples = 4096; +static constexpr int kRealtimeFXTailTimerMilliseconds = 250; +static constexpr double kRealtimeFXTailSafetySeconds = 1.0; +static constexpr double kRealtimeFXTailQuietWindowSeconds = 0.65; +static constexpr float kRealtimeFXTailQuietPeak = 3.1622776601683795e-5f; // -90 dBFS +static constexpr juce::uint32 kMIDIActivityHoldMilliseconds = 90; +static constexpr juce::uint32 kMIDIActivityDecayMilliseconds = 360; + +namespace +{ +float decayMIDIActivity(float level, juce::uint32 ageMilliseconds) noexcept +{ + if (ageMilliseconds <= kMIDIActivityHoldMilliseconds) + return level; + + const auto decayAge = ageMilliseconds - kMIDIActivityHoldMilliseconds; + if (decayAge >= kMIDIActivityDecayMilliseconds) + return 0.0f; + + return level * (1.0f - static_cast<float>(decayAge) + / static_cast<float>(kMIDIActivityDecayMilliseconds)); +} + +float getMIDIMessageActivity(const juce::MidiMessage& message) noexcept +{ + if (message.isNoteOn()) + return juce::jlimit(0.0f, 1.0f, message.getFloatVelocity()); + if (message.isController()) + return juce::jmax(0.12f, juce::jlimit(0.0f, 1.0f, + static_cast<float>(message.getControllerValue()) / 127.0f)); + if (message.isAftertouch()) + return juce::jmax(0.12f, juce::jlimit(0.0f, 1.0f, + static_cast<float>(message.getAfterTouchValue()) / 127.0f)); + if (message.isChannelPressure()) + return juce::jmax(0.12f, juce::jlimit(0.0f, 1.0f, + static_cast<float>(message.getChannelPressureValue()) / 127.0f)); + if (message.isPitchWheel()) + return juce::jmax(0.12f, juce::jlimit(0.0f, 1.0f, + static_cast<float>(message.getPitchWheelValue()) / 16383.0f)); + if (message.isProgramChange()) + return 0.5f; + + // Note-off and transport/clock/active-sensing messages must not keep an + // armed track's input meter illuminated. + return 0.0f; +} + +class ScopedTrackRealtimeReader final +{ +public: + explicit ScopedTrackRealtimeReader( + std::atomic<std::uint32_t>& readersToUse, + bool active = true) noexcept + : readers(active ? &readersToUse : nullptr) + { + if (readers != nullptr) + readers->fetch_add(1, std::memory_order_seq_cst); + } + + ~ScopedTrackRealtimeReader() + { + if (readers != nullptr) + readers->fetch_sub(1, std::memory_order_seq_cst); + } + + ScopedTrackRealtimeReader( + const ScopedTrackRealtimeReader&) = delete; + ScopedTrackRealtimeReader& operator=( + const ScopedTrackRealtimeReader&) = delete; + +private: + std::atomic<std::uint32_t>* readers = nullptr; +}; +} + +// juce::MidiOutput::sendBlockOfMessages() allocates one PendingMessage and +// takes an internal CriticalSection for every event. Keep that entire code +// path, along with device lifetime changes, off the audio callback. +class TrackMIDIOutputDispatcher final : private juce::Thread +{ +public: + TrackMIDIOutputDispatcher() + : juce::Thread("OpenStudio Track MIDI Sender") + { + } + + ~TrackMIDIOutputDispatcher() override + { + connected.store(false, std::memory_order_release); + generation.fetch_add(1, std::memory_order_acq_rel); + signalThreadShouldExit(); + stopThread(2000); + + const juce::ScopedLock sl(outputLock); + output.reset(); + outputDeviceName.clear(); + } + + bool connect(const juce::String& deviceName) + { + disconnect(); + + std::unique_ptr<juce::MidiOutput> newOutput; + for (const auto& device : juce::MidiOutput::getAvailableDevices()) + { + if (device.name == deviceName) + { + newOutput = juce::MidiOutput::openDevice(device.identifier); + break; + } + } + + if (newOutput == nullptr) + return false; + + { + const juce::ScopedLock sl(outputLock); + output = std::move(newOutput); + outputDeviceName = deviceName; + generation.fetch_add(1, std::memory_order_acq_rel); + connected.store(true, std::memory_order_release); + } + + if (!isThreadRunning() + && !startThread(juce::Thread::Priority::high)) + { + disconnect(); + return false; + } + + notify(); + return true; + } + + void disconnect() + { + connected.store(false, std::memory_order_release); + generation.fetch_add(1, std::memory_order_acq_rel); + notify(); + + const juce::ScopedLock sl(outputLock); + output.reset(); + outputDeviceName.clear(); + } + + bool isConnected() const noexcept + { + return connected.load(std::memory_order_acquire); + } + + juce::String getDeviceName() const + { + const juce::ScopedLock sl(outputLock); + return outputDeviceName; + } + + void enqueueBuffer(const juce::MidiBuffer& buffer, + double sampleRate, + bool resetMessagesOnly) noexcept + { + if (!connected.load(std::memory_order_acquire) || buffer.isEmpty()) + return; + + const double safeSampleRate = + sampleRate > 0.0 ? sampleRate : 44100.0; + const double blockStartMs = + juce::Time::getMillisecondCounterHiRes(); + const double millisecondsPerSample = 1000.0 / safeSampleRate; + + for (const auto metadata : buffer) + { + if (resetMessagesOnly + && !isResetMessage( + metadata.data, + metadata.numBytes)) + { + continue; + } + + const double eventTimeMs = + blockStartMs + + static_cast<double>( + juce::jmax(0, metadata.samplePosition)) + * millisecondsPerSample; + enqueueMessage( + metadata.data, + metadata.numBytes, + eventTimeMs); + } + } + +private: + static constexpr int kMaxMessageBytes = 256; + + struct Packet + { + std::array<std::uint8_t, kMaxMessageBytes> bytes {}; + double eventTimeMs = 0.0; + std::uint32_t generation = 0; + std::uint16_t size = 0; + }; + + static constexpr std::uint32_t kQueueCapacity = 512; + static_assert( + (kQueueCapacity & (kQueueCapacity - 1)) == 0); + static_assert(std::atomic<bool>::is_always_lock_free); + static_assert( + std::atomic<std::uint32_t>::is_always_lock_free); + + static bool isResetMessage(const std::uint8_t* bytes, + int size) noexcept + { + if (bytes == nullptr || size < 1) + return false; + + const auto status = + static_cast<std::uint8_t>(bytes[0] & 0xf0u); + if (status == 0xb0u && size >= 3) + { + const auto controller = bytes[1]; + return controller == 64u + || controller == 120u + || controller == 121u + || controller == 123u; + } + + if (status == 0xe0u && size >= 3) + { + const int pitchWheel = + static_cast<int>(bytes[1]) + | (static_cast<int>(bytes[2]) << 7); + return pitchWheel == 8192; + } + + return false; + } + + bool enqueueMessage(const std::uint8_t* bytes, + int size, + double eventTimeMs) noexcept + { + if (bytes == nullptr + || size <= 0 + || size > kMaxMessageBytes + || !connected.load(std::memory_order_acquire)) + { + if (size > kMaxMessageBytes) + oversizedMessageCount.fetch_add( + 1, std::memory_order_relaxed); + return false; + } + + const auto write = + writePosition.load(std::memory_order_relaxed); + const auto read = + readPosition.load(std::memory_order_acquire); + if (write - read >= kQueueCapacity) + { + droppedMessageCount.fetch_add( + 1, std::memory_order_relaxed); + return false; + } + + auto& packet = + queue[write & (kQueueCapacity - 1)]; + packet.eventTimeMs = eventTimeMs; + packet.generation = + generation.load(std::memory_order_acquire); + packet.size = static_cast<std::uint16_t>(size); + for (int index = 0; index < size; ++index) + { + packet.bytes[static_cast<std::size_t>(index)] = + bytes[index]; + } + + writePosition.store( + write + 1, std::memory_order_release); + return true; + } + + bool dequeue(Packet& packet) noexcept + { + const auto read = + readPosition.load(std::memory_order_relaxed); + if (read == writePosition.load( + std::memory_order_acquire)) + { + return false; + } + + packet = queue[read & (kQueueCapacity - 1)]; + readPosition.store( + read + 1, std::memory_order_release); + return true; + } + + void run() override + { + Packet packet; + bool hasPacket = false; + + while (!threadShouldExit()) + { + if (!hasPacket) + hasPacket = dequeue(packet); + + if (!hasPacket) + { + const bool active = + connected.load(std::memory_order_relaxed); + wait(active ? 1 : 20); + continue; + } + + const auto currentGeneration = + generation.load(std::memory_order_acquire); + if (!connected.load(std::memory_order_acquire) + || packet.generation != currentGeneration) + { + hasPacket = false; + continue; + } + + const double nowMs = + juce::Time::getMillisecondCounterHiRes(); + const double remainingMs = + packet.eventTimeMs - nowMs; + if (remainingMs > 0.75) + { + wait(juce::jlimit( + 1, + 20, + static_cast<int>( + std::floor(remainingMs)))); + continue; + } + + { + const juce::ScopedLock sl(outputLock); + if (output != nullptr + && connected.load( + std::memory_order_acquire) + && packet.generation + == generation.load( + std::memory_order_acquire)) + { + output->sendMessageNow( + juce::MidiMessage( + packet.bytes.data(), + static_cast<int>(packet.size))); + } + } + + hasPacket = false; + } + } + + std::array<Packet, kQueueCapacity> queue {}; + std::atomic<std::uint32_t> writePosition { 0 }; + std::atomic<std::uint32_t> readPosition { 0 }; + std::atomic<std::uint32_t> generation { 1 }; + std::atomic<std::uint32_t> droppedMessageCount { 0 }; + std::atomic<std::uint32_t> oversizedMessageCount { 0 }; + std::atomic<bool> connected { false }; + mutable juce::CriticalSection outputLock; + std::unique_ptr<juce::MidiOutput> output; + juce::String outputDeviceName; +}; static bool isBuiltInInstrumentProcessor(const juce::AudioProcessor* processor) { @@ -16,9 +389,27 @@ static bool isBuiltInInstrumentProcessor(const juce::AudioProcessor* processor) return name == "OpenStudio Piano" || name == "OpenStudio Drums" || name == "OpenStudio Basic Synth" + || name == "OpenStudio Clean Guitar" || name == "Studio13 Piano" || name == "Studio13 Drums" - || name == "Studio13 Basic Synth"; + || name == "Studio13 Basic Synth" + || name == "Studio13 Clean Guitar"; +} + +static bool hasInternalAuditionSourceActive( + const std::vector<std::shared_ptr<juce::AudioProcessor>>* processors) +{ + if (processors == nullptr) + return false; + + for (const auto& processor : *processors) + { + if (auto* rack = dynamic_cast<S13NAMRack*>(processor.get())) + if (rack->hasAuditionSourceActive()) + return true; + } + + return false; } // Debug logging — always active for FX diagnostics @@ -247,11 +638,83 @@ static void applyStereoWidthToBuffer(juce::AudioBuffer<float>& buffer, } } +void TrackProcessor::registerMIDIInputActivity( + const juce::MidiMessage& message) noexcept +{ + const auto activity = getMIDIMessageActivity(message); + if (activity <= 0.0f) + return; + + const auto now = juce::Time::getMillisecondCounter(); + const auto previousTimestamp = midiInputActivityTimestampMs.load( + std::memory_order_acquire); + const auto previousLevel = midiInputActivityLevel.load( + std::memory_order_acquire); + const auto decayedPrevious = previousTimestamp == 0 + ? 0.0f + : decayMIDIActivity(previousLevel, now - previousTimestamp); + + midiInputActivityLevel.store( + juce::jmax(activity, decayedPrevious), + std::memory_order_release); + midiInputActivityTimestampMs.store(now, std::memory_order_release); +} + +float TrackProcessor::getMIDIInputActivityLevel() const noexcept +{ + const auto timestamp = midiInputActivityTimestampMs.load( + std::memory_order_acquire); + if (timestamp == 0) + return 0.0f; + + const auto level = midiInputActivityLevel.load(std::memory_order_acquire); + return decayMIDIActivity( + level, + juce::Time::getMillisecondCounter() - timestamp); +} + TrackProcessor::TrackProcessor() : AudioProcessor (BusesProperties() .withInput ("Input", juce::AudioChannelSet::stereo(), true) - .withOutput ("Output", juce::AudioChannelSet::stereo(), true)) -{ + .withOutput ("Output", juce::AudioChannelSet::stereo(), true)), + midiOutputDispatcher( + std::make_unique<TrackMIDIOutputDispatcher>()) +{ + // Give the inline Channel Strip EQ its own explicit six-band contract. + // S13EQ's plug-in defaults are an eight-band layout, so relying on them + // made the strip's HPF/LPF labels disagree with the filters that actually + // ran until a user moved each control at least once. + static constexpr std::array<float, channelStripEQBandCount> stripFrequencies { + 80.0f, 200.0f, 1000.0f, 3000.0f, 8000.0f, 18000.0f + }; + static constexpr std::array<float, channelStripEQBandCount> stripQ { + 0.707f, 0.707f, 1.0f, 1.0f, 0.707f, 0.707f + }; + for (int bandIndex = 0; bandIndex < channelStripEQBandCount; ++bandIndex) + { + auto& band = channelStripEQ.bands[static_cast<size_t>(bandIndex)]; + band.type.store( + static_cast<float>( + bandIndex == 0 + ? S13EQ::FilterType::LowCut + : bandIndex == channelStripEQBandCount - 1 + ? S13EQ::FilterType::HighCut + : S13EQ::FilterType::Bell), + std::memory_order_relaxed); + band.freq.store(stripFrequencies[static_cast<size_t>(bandIndex)], + std::memory_order_relaxed); + band.gain.store(0.0f, std::memory_order_relaxed); + band.q.store(stripQ[static_cast<size_t>(bandIndex)], + std::memory_order_relaxed); + band.enabled.store(0.0f, std::memory_order_relaxed); + } + for (int bandIndex = channelStripEQBandCount; + bandIndex < S13EQ::numBands; + ++bandIndex) + { + channelStripEQ.bands[static_cast<size_t>(bandIndex)].enabled.store( + 0.0f, std::memory_order_relaxed); + } widthAutomation.setDefaultValue(widthPercentToBackend(stereoWidth.load(std::memory_order_relaxed))); preFXVolumeAutomation.setDefaultValue(0.0f); preFXPanAutomation.setDefaultValue(0.0f); @@ -261,13 +724,10 @@ TrackProcessor::TrackProcessor() midiVelocityScaleAutomation.setDefaultValue(1.0f); midiPitchBendAutomation.setDefaultValue(0.0f); midiChannelPressureAutomation.setDefaultValue(0.0f); - std::atomic_store_explicit(&pluginAutomationSnapshot, - std::make_shared<const PluginAutomationRouteSnapshot>(), - std::memory_order_release); - std::atomic_store_explicit(&midiCCAutomationSnapshot, - std::make_shared<const MIDICCAutomationRouteSnapshot>(), - std::memory_order_release); - midiOutputResetBuffer.ensureSize(512); + publishPluginAutomationRoutes( + std::make_shared<const PluginAutomationRouteSnapshot>()); + publishMIDICCAutomationRoutes( + std::make_shared<const MIDICCAutomationRouteSnapshot>()); for (size_t channel = 0; channel < midiNoteCurrentlyActive.size(); ++channel) { for (size_t note = 0; note < midiNoteCurrentlyActive[channel].size(); ++note) @@ -279,14 +739,61 @@ TrackProcessor::TrackProcessor() } } publishRealtimeStateSnapshots(); + startTimer(kRealtimeFXTailTimerMilliseconds); } TrackProcessor::~TrackProcessor() { + stopTimer(); + hasScheduledMIDIClipsForAudio.store( + false, std::memory_order_release); + scheduledMIDIClipsForAudio.store( + nullptr, std::memory_order_seq_cst); + realtimeGraphSnapshotForAudio.store( + nullptr, std::memory_order_seq_cst); + pluginAutomationSnapshotForAudio.store( + nullptr, std::memory_order_seq_cst); + midiCCAutomationSnapshotForAudio.store( + nullptr, std::memory_order_seq_cst); + fallbackSamplerSampleForAudio.store( + nullptr, std::memory_order_seq_cst); + jassert( + scheduledMIDIAudioReaders.load( + std::memory_order_seq_cst) == 0); + jassert( + realtimeGraphAudioReaders.load( + std::memory_order_seq_cst) == 0); + jassert( + realtimeAuxAudioReaders.load( + std::memory_order_seq_cst) == 0); + reclaimRetiredScheduledMIDISnapshots(); + reclaimRetiredRealtimeGraphSnapshots(); + reclaimRetiredRealtimeAuxOwners(); } std::optional<TrackProcessor::PluginAutomationParameterRef> TrackProcessor::parsePluginAutomationParameterId(const juce::String& parameterId) const { + if (parameterId.startsWith("builtin_")) + { + const auto suffix = parameterId.substring(8); + const int firstSeparator = suffix.indexOfChar('_'); + const int secondSeparator = suffix.indexOfChar(firstSeparator + 1, '_'); + if (firstSeparator <= 0 || secondSeparator <= firstSeparator + 1) + return std::nullopt; + + const auto chain = suffix.substring(0, firstSeparator); + PluginAutomationParameterRef ref; + if (chain != "input" && chain != "track") + return std::nullopt; + + ref.isInputFX = chain == "input"; + ref.fxIndex = suffix.substring(firstSeparator + 1, secondSeparator).getIntValue(); + ref.builtInParamId = juce::URL::removeEscapeChars(suffix.substring(secondSeparator + 1)); + if (ref.fxIndex < 0 || ref.builtInParamId.isEmpty()) + return std::nullopt; + return ref; + } + if (!parameterId.startsWith("plugin_")) return std::nullopt; @@ -337,6 +844,162 @@ std::shared_ptr<TrackProcessor::PluginAutomationRoute> TrackProcessor::findPlugi return nullptr; } +std::shared_ptr<TrackProcessor::PluginAutomationRoute> +TrackProcessor::clonePluginAutomationRoute( + const PluginAutomationRoute& source) +{ + auto clone = std::make_shared<PluginAutomationRoute>(); + clone->parameterId = source.parameterId; + clone->isInputFX = source.isInputFX; + clone->fxIndex = source.fxIndex; + clone->targetProcessor = source.targetProcessor; + clone->paramIndex = source.paramIndex; + clone->builtInParamId = source.builtInParamId; + clone->builtInMinimum = source.builtInMinimum; + clone->builtInMaximum = source.builtInMaximum; + clone->builtInDiscrete = source.builtInDiscrete; + clone->builtInCurve = source.builtInCurve; + clone->automation = source.automation; + clone->lastAppliedValue.store( + source.lastAppliedValue.load(std::memory_order_acquire), + std::memory_order_relaxed); + return clone; +} + +void TrackProcessor::publishPluginAutomationRoutes( + std::shared_ptr<const PluginAutomationRouteSnapshot> snapshot) +{ + const bool hasRoutes = snapshot != nullptr && !snapshot->empty(); + const juce::ScopedLock publicationGuard( + realtimeAuxPublicationLock); + reclaimRetiredRealtimeAuxOwners(); + const auto previous = std::atomic_load_explicit( + &pluginAutomationSnapshot, + std::memory_order_acquire); + { + const juce::ScopedLock retirementGuard( + realtimeAuxRetirementLock); + if (previous != nullptr + && previous.get() != snapshot.get()) + { + retiredRealtimeAuxOwners.push_back( + std::static_pointer_cast<const void>( + previous)); + } + std::atomic_store_explicit( + &pluginAutomationSnapshot, + snapshot, + std::memory_order_release); + pluginAutomationSnapshotForAudio.store( + snapshot.get(), + std::memory_order_seq_cst); + } + hasPublishedPluginAutomationRoutes.store(hasRoutes, std::memory_order_release); +} + +void TrackProcessor::remapPluginAutomationRoutesForReorder( + bool isInputFX, int fromIndex, int toIndex) +{ + const juce::ScopedLock routeGuard(pluginAutomationRouteLock); + const auto snapshot = std::atomic_load_explicit( + &pluginAutomationSnapshot, std::memory_order_acquire); + if (snapshot == nullptr || snapshot->empty()) + return; + + auto nextSnapshot = std::make_shared<PluginAutomationRouteSnapshot>(); + nextSnapshot->reserve(snapshot->size()); + for (const auto& route : *snapshot) + { + if (route == nullptr) + { + nextSnapshot->push_back(route); + continue; + } + + // Route objects already visible to the callback are immutable. Clone + // before changing Strings or indices so the old reader epoch remains + // race-free until retirement. + auto nextRoute = clonePluginAutomationRoute(*route); + if (route->isInputFX != isInputFX) + { + nextSnapshot->push_back(std::move(nextRoute)); + continue; + } + + int mappedIndex = route->fxIndex; + if (mappedIndex == fromIndex) + mappedIndex = toIndex; + else if (fromIndex < toIndex + && mappedIndex > fromIndex + && mappedIndex <= toIndex) + --mappedIndex; + else if (fromIndex > toIndex + && mappedIndex >= toIndex + && mappedIndex < fromIndex) + ++mappedIndex; + + nextRoute->fxIndex = mappedIndex; + const auto chain = isInputFX ? "input" : "track"; + nextRoute->parameterId = nextRoute->builtInParamId.isNotEmpty() + ? "builtin_" + juce::String(chain) + "_" + juce::String(mappedIndex) + + "_" + juce::URL::addEscapeChars(nextRoute->builtInParamId, true) + : "plugin_" + juce::String(chain) + "_" + juce::String(mappedIndex) + + "_" + juce::String(nextRoute->paramIndex); + nextRoute->lastAppliedValue.store( + std::numeric_limits<float>::quiet_NaN(), + std::memory_order_release); + nextSnapshot->push_back(std::move(nextRoute)); + } + + publishPluginAutomationRoutes( + std::static_pointer_cast<const PluginAutomationRouteSnapshot>(nextSnapshot)); +} + +void TrackProcessor::remapPluginAutomationRoutesForRemoval( + bool isInputFX, int removedIndex) +{ + const juce::ScopedLock routeGuard(pluginAutomationRouteLock); + const auto snapshot = std::atomic_load_explicit( + &pluginAutomationSnapshot, std::memory_order_acquire); + if (snapshot == nullptr || snapshot->empty()) + return; + + auto nextSnapshot = std::make_shared<PluginAutomationRouteSnapshot>(); + nextSnapshot->reserve(snapshot->size()); + for (const auto& route : *snapshot) + { + if (route == nullptr) + { + nextSnapshot->push_back(route); + continue; + } + if (route->isInputFX == isInputFX + && route->fxIndex == removedIndex) + continue; + + auto nextRoute = clonePluginAutomationRoute(*route); + + if (route->isInputFX == isInputFX + && route->fxIndex > removedIndex) + { + --nextRoute->fxIndex; + const auto chain = isInputFX ? "input" : "track"; + nextRoute->parameterId = nextRoute->builtInParamId.isNotEmpty() + ? "builtin_" + juce::String(chain) + "_" + juce::String(nextRoute->fxIndex) + + "_" + juce::URL::addEscapeChars(nextRoute->builtInParamId, true) + : "plugin_" + juce::String(chain) + "_" + juce::String(nextRoute->fxIndex) + + "_" + juce::String(nextRoute->paramIndex); + nextRoute->lastAppliedValue.store( + std::numeric_limits<float>::quiet_NaN(), + std::memory_order_release); + } + nextSnapshot->push_back(std::move(nextRoute)); + } + + publishPluginAutomationRoutes( + std::static_pointer_cast<const PluginAutomationRouteSnapshot>(nextSnapshot)); +} + std::shared_ptr<TrackProcessor::PluginAutomationRoute> TrackProcessor::getOrCreatePluginAutomationRoute(const juce::String& parameterId) { if (auto existing = findPluginAutomationRoute(parameterId)) @@ -352,6 +1015,7 @@ std::shared_ptr<TrackProcessor::PluginAutomationRoute> TrackProcessor::getOrCrea route->isInputFX = parsedRef.isInputFX; route->fxIndex = parsedRef.fxIndex; route->paramIndex = parsedRef.paramIndex; + route->builtInParamId = parsedRef.builtInParamId; const bool validRoute = route->isInputFX ? route->fxIndex < getNumInputFX() @@ -359,6 +1023,24 @@ std::shared_ptr<TrackProcessor::PluginAutomationRoute> TrackProcessor::getOrCrea if (!validRoute) return nullptr; + auto* processor = route->isInputFX + ? getInputFXProcessor(route->fxIndex) + : getTrackFXProcessor(route->fxIndex); + route->targetProcessor = processor; + if (route->builtInParamId.isNotEmpty()) + { + OpenStudioBuiltInAutomationDescriptor descriptor; + if (! getOpenStudioBuiltInAutomationDescriptor(processor, route->builtInParamId, descriptor)) + return nullptr; + route->builtInMinimum = descriptor.minimum; + route->builtInMaximum = descriptor.maximum; + route->builtInDiscrete = descriptor.discrete; + route->builtInCurve = descriptor.curve; + route->automation->setDefaultValue( + openStudioBuiltInValueToNormalized( + descriptor, descriptor.currentValue)); + } + const juce::ScopedLock sl(pluginAutomationRouteLock); if (auto existing = findPluginAutomationRoute(parameterId)) return existing; @@ -368,9 +1050,8 @@ std::shared_ptr<TrackProcessor::PluginAutomationRoute> TrackProcessor::getOrCrea if (snapshot) *nextSnapshot = *snapshot; nextSnapshot->push_back(route); - std::atomic_store_explicit(&pluginAutomationSnapshot, - std::static_pointer_cast<const PluginAutomationRouteSnapshot>(nextSnapshot), - std::memory_order_release); + publishPluginAutomationRoutes( + std::static_pointer_cast<const PluginAutomationRouteSnapshot>(nextSnapshot)); return route; } @@ -398,6 +1079,37 @@ std::shared_ptr<TrackProcessor::MIDICCAutomationRoute> TrackProcessor::findMIDIC return nullptr; } +void TrackProcessor::publishMIDICCAutomationRoutes( + std::shared_ptr<const MIDICCAutomationRouteSnapshot> snapshot) +{ + const bool hasRoutes = snapshot != nullptr && !snapshot->empty(); + const juce::ScopedLock publicationGuard( + realtimeAuxPublicationLock); + reclaimRetiredRealtimeAuxOwners(); + const auto previous = std::atomic_load_explicit( + &midiCCAutomationSnapshot, + std::memory_order_acquire); + { + const juce::ScopedLock retirementGuard( + realtimeAuxRetirementLock); + if (previous != nullptr + && previous.get() != snapshot.get()) + { + retiredRealtimeAuxOwners.push_back( + std::static_pointer_cast<const void>( + previous)); + } + std::atomic_store_explicit( + &midiCCAutomationSnapshot, + snapshot, + std::memory_order_release); + midiCCAutomationSnapshotForAudio.store( + snapshot.get(), + std::memory_order_seq_cst); + } + hasPublishedMIDICCAutomationRoutes.store(hasRoutes, std::memory_order_release); +} + std::shared_ptr<TrackProcessor::MIDICCAutomationRoute> TrackProcessor::getOrCreateMIDICCAutomationRoute(const juce::String& parameterId) { if (auto existing = findMIDICCAutomationRoute(parameterId)) @@ -421,9 +1133,8 @@ std::shared_ptr<TrackProcessor::MIDICCAutomationRoute> TrackProcessor::getOrCrea if (snapshot) *nextSnapshot = *snapshot; nextSnapshot->push_back(route); - std::atomic_store_explicit(&midiCCAutomationSnapshot, - std::static_pointer_cast<const MIDICCAutomationRouteSnapshot>(nextSnapshot), - std::memory_order_release); + publishMIDICCAutomationRoutes( + std::static_pointer_cast<const MIDICCAutomationRouteSnapshot>(nextSnapshot)); return route; } @@ -516,6 +1227,7 @@ std::optional<TrackProcessor::AutomationTarget> TrackProcessor::resolveAutomatio target.isInputFX = route->isInputFX; target.fxIndex = route->fxIndex; target.paramIndex = route->paramIndex; + target.builtInParamId = route->builtInParamId; return target; } @@ -559,6 +1271,20 @@ float TrackProcessor::getAutomationDefaultValue(const AutomationTarget& target) if (processor == nullptr) return 0.0f; + if (target.builtInParamId.isNotEmpty()) + { + OpenStudioBuiltInAutomationDescriptor descriptor; + if (! getOpenStudioBuiltInAutomationDescriptor( + const_cast<juce::AudioProcessor*>(processor), + target.builtInParamId, + descriptor)) + { + return 0.0f; + } + return openStudioBuiltInValueToNormalized( + descriptor, descriptor.currentValue); + } + const auto& params = processor->getParameters(); if (target.paramIndex < 0 || target.paramIndex >= params.size() || params[target.paramIndex] == nullptr) return 0.0f; @@ -572,8 +1298,15 @@ float TrackProcessor::getAutomationDefaultValue(const AutomationTarget& target) bool TrackProcessor::hasPluginAutomation() const { - auto snapshot = std::atomic_load_explicit(&pluginAutomationSnapshot, std::memory_order_acquire); - if (!snapshot) + if (!hasPublishedPluginAutomationRoutes.load(std::memory_order_acquire)) + return false; + + const ScopedTrackRealtimeReader readGuard( + realtimeAuxAudioReaders); + const auto* const snapshot = + pluginAutomationSnapshotForAudio.load( + std::memory_order_seq_cst); + if (snapshot == nullptr) return false; for (const auto& route : *snapshot) @@ -593,8 +1326,15 @@ bool TrackProcessor::hasMIDIAutomation() const if (midiChannelPressureAutomation.shouldPlaybackForRead() && midiChannelPressureAutomation.getNumPoints() > 0) return true; - auto snapshot = std::atomic_load_explicit(&midiCCAutomationSnapshot, std::memory_order_acquire); - if (!snapshot) + if (!hasPublishedMIDICCAutomationRoutes.load(std::memory_order_acquire)) + return false; + + const ScopedTrackRealtimeReader readGuard( + realtimeAuxAudioReaders); + const auto* const snapshot = + midiCCAutomationSnapshotForAudio.load( + std::memory_order_seq_cst); + if (snapshot == nullptr) return false; for (const auto& route : *snapshot) @@ -626,83 +1366,399 @@ void TrackProcessor::resetAutomationTouchState() midiPitchBendAutomation.resetTouchAndLatch(); midiChannelPressureAutomation.resetTouchAndLatch(); - auto pluginSnapshot = std::atomic_load_explicit(&pluginAutomationSnapshot, std::memory_order_acquire); - if (pluginSnapshot) - for (const auto& route : *pluginSnapshot) - if (route && route->automation) - route->automation->resetTouchAndLatch(); + if (hasPublishedPluginAutomationRoutes.load(std::memory_order_acquire)) + { + auto pluginSnapshot = std::atomic_load_explicit( + &pluginAutomationSnapshot, std::memory_order_acquire); + if (pluginSnapshot) + for (const auto& route : *pluginSnapshot) + if (route && route->automation) + route->automation->resetTouchAndLatch(); + } + + if (hasPublishedMIDICCAutomationRoutes.load(std::memory_order_acquire)) + { + auto midiSnapshot = std::atomic_load_explicit( + &midiCCAutomationSnapshot, std::memory_order_acquire); + if (midiSnapshot) + for (const auto& route : *midiSnapshot) + if (route && route->automation) + route->automation->resetTouchAndLatch(); + } +} + +void TrackProcessor::reclaimRetiredRealtimeGraphSnapshots() +{ + std::vector<std::shared_ptr<const RealtimeGraphSnapshot>> + reclaim; + { + const juce::ScopedLock retirementGuard( + realtimeGraphRetirementLock); + if (realtimeGraphAudioReaders.load( + std::memory_order_seq_cst) == 0) + { + reclaim.swap( + retiredRealtimeGraphSnapshots); + } + } +} + +void TrackProcessor::reclaimRetiredRealtimeAuxOwners() +{ + std::vector<std::shared_ptr<const void>> reclaim; + { + const juce::ScopedLock retirementGuard( + realtimeAuxRetirementLock); + if (realtimeAuxAudioReaders.load( + std::memory_order_seq_cst) == 0) + { + reclaim.swap( + retiredRealtimeAuxOwners); + } + } +} + +void TrackProcessor::reclaimRetiredScheduledMIDISnapshots() +{ + std::vector<std::shared_ptr<const std::vector<ScheduledMIDIClip>>> + reclaim; + { + const juce::ScopedLock retirementGuard( + scheduledMIDIRetirementLock); + if (scheduledMIDIAudioReaders.load( + std::memory_order_seq_cst) == 0) + { + reclaim.swap( + retiredScheduledMIDISnapshots); + } + } +} - auto midiSnapshot = std::atomic_load_explicit(&midiCCAutomationSnapshot, std::memory_order_acquire); - if (midiSnapshot) - for (const auto& route : *midiSnapshot) - if (route && route->automation) - route->automation->resetTouchAndLatch(); +void TrackProcessor::publishScheduledMIDIClips( + std::shared_ptr<const std::vector<ScheduledMIDIClip>> snapshot) +{ + const juce::ScopedLock publicationGuard( + scheduledMIDIPublicationLock); + reclaimRetiredScheduledMIDISnapshots(); + const auto previous = std::atomic_load_explicit( + &scheduledMIDIClips, std::memory_order_acquire); + { + const juce::ScopedLock retirementGuard( + scheduledMIDIRetirementLock); + if (previous != nullptr + && previous.get() != snapshot.get()) + { + retiredScheduledMIDISnapshots.push_back( + previous); + } + std::atomic_store_explicit( + &scheduledMIDIClips, + snapshot, + std::memory_order_release); + const bool hasScheduledClips = + snapshot != nullptr + && ! snapshot->empty(); + scheduledMIDIClipsForAudio.store( + hasScheduledClips + ? snapshot.get() + : nullptr, + std::memory_order_seq_cst); + hasScheduledMIDIClipsForAudio.store( + hasScheduledClips, + std::memory_order_release); + } } void TrackProcessor::publishRealtimeStateSnapshots() { - auto inputSnapshot = std::make_shared<const ProcessorSnapshot>(inputFXPlugins.begin(), inputFXPlugins.end()); - auto trackSnapshot = std::make_shared<const ProcessorSnapshot>(trackFXPlugins.begin(), trackFXPlugins.end()); - auto sidechainSnapshot = std::make_shared<const SidechainSourceSnapshot>(sidechainSources.begin(), sidechainSources.end()); - auto sendSnapshot = std::make_shared<const SendSnapshot>(sends.begin(), sends.end()); - auto inputBypassSnapshot = std::make_shared<const BypassSnapshot>(inputFXBypassedState.begin(), inputFXBypassedState.end()); - auto trackBypassSnapshot = std::make_shared<const BypassSnapshot>(trackFXBypassedState.begin(), trackFXBypassedState.end()); - auto inputPrecisionSnapshot = std::make_shared<const PrecisionOverrideSnapshot>(inputFXForceFloatOverrides.begin(), inputFXForceFloatOverrides.end()); - auto trackPrecisionSnapshot = std::make_shared<const PrecisionOverrideSnapshot>(trackFXForceFloatOverrides.begin(), trackFXForceFloatOverrides.end()); - std::shared_ptr<juce::AudioProcessor> instrumentSnapshot = instrumentPlugin; - - std::atomic_store_explicit(&realtimeInputFXSnapshot, inputSnapshot, std::memory_order_release); - std::atomic_store_explicit(&realtimeTrackFXSnapshot, trackSnapshot, std::memory_order_release); - std::atomic_store_explicit(&realtimeInputFXBypassSnapshot, inputBypassSnapshot, std::memory_order_release); - std::atomic_store_explicit(&realtimeTrackFXBypassSnapshot, trackBypassSnapshot, std::memory_order_release); - std::atomic_store_explicit(&realtimeInputFXPrecisionOverrideSnapshot, inputPrecisionSnapshot, std::memory_order_release); - std::atomic_store_explicit(&realtimeTrackFXPrecisionOverrideSnapshot, trackPrecisionSnapshot, std::memory_order_release); - std::atomic_store_explicit(&realtimeSidechainSnapshot, sidechainSnapshot, std::memory_order_release); - std::atomic_store_explicit(&realtimeSendSnapshot, sendSnapshot, std::memory_order_release); - std::atomic_store_explicit(&realtimeInstrumentSnapshot, instrumentSnapshot, std::memory_order_release); + // Serialise control-side publishers. The callback never acquires this lock. + const juce::ScopedLock publicationGuard( + realtimeGraphPublicationLock); + // Reclaim only owners retired by an earlier publication. The owner replaced + // below must survive at least one publication boundary so a reader that + // starts concurrently can still observe it safely. + reclaimRetiredRealtimeGraphSnapshots(); + const auto previous = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + auto next = std::make_shared<RealtimeGraphSnapshot>(); + next->generation = + realtimeGraphGeneration.fetch_add(1, std::memory_order_relaxed) + 1; + next->inputFX.assign(inputFXPlugins.begin(), inputFXPlugins.end()); + next->trackFX.assign(trackFXPlugins.begin(), trackFXPlugins.end()); + next->inputFXBypass = inputFXBypassedState; + next->trackFXBypass = trackFXBypassedState; + next->inputFXPrecisionOverrides = inputFXForceFloatOverrides; + next->trackFXPrecisionOverrides = trackFXForceFloatOverrides; + next->instrument = instrumentPlugin; + next->sidechainSources = sidechainSources; + next->sends = sends; + + const int preparedBlockSize = juce::jmax( + 1, + fxBypassDryBuffer.getNumSamples() > 0 + ? fxBypassDryBuffer.getNumSamples() + : kMinimumHostedPluginBlockSize); + const auto prepareDelayStorage = + [preparedBlockSize] ( + const ProcessorPtr& processor, + const FXBypassDelayStoragePtr& reusable) + -> FXBypassDelayStoragePtr + { + if (processor == nullptr) + return {}; + + const int reportedLatency = + juce::jmax(0, processor->getLatencySamples()); + const int requiredCapacity = + juce::jmax( + kHostBypassLatencyHeadroomSamples, + reportedLatency) + + preparedBlockSize + 1; + if (reusable != nullptr + && reusable->processor == processor.get() + && reusable->ring.getNumChannels() + >= hostBypassDryChannels + && reusable->ring.getNumSamples() + >= requiredCapacity) + { + reusable->publishedLatency.store( + reportedLatency, + std::memory_order_release); + return reusable; + } + + auto storage = + std::make_shared<FXBypassDelayStorage>(); + storage->processor = processor.get(); + storage->publishedLatency.store( + reportedLatency, + std::memory_order_relaxed); + storage->ring.setSize( + hostBypassDryChannels, + requiredCapacity, + false, + true, + false); + storage->ring.clear(); + return storage; + }; + + for (size_t index = 0; + index < maxRealtimeFXContinuitySlots; + ++index) + { + const auto previousInput = + previous != nullptr + ? previous->inputFXBypassDelay[index] + : FXBypassDelayStoragePtr {}; + const auto previousTrack = + previous != nullptr + ? previous->trackFXBypassDelay[index] + : FXBypassDelayStoragePtr {}; + if (index < next->inputFX.size()) + { + next->inputFXBypassDelay[index] = + prepareDelayStorage( + next->inputFX[index], + previousInput); + } + if (index < next->trackFX.size()) + { + next->trackFXBypassDelay[index] = + prepareDelayStorage( + next->trackFX[index], + previousTrack); + } + } + + const auto published = + std::static_pointer_cast<const RealtimeGraphSnapshot>( + next); + { + const juce::ScopedLock retirementGuard( + realtimeGraphRetirementLock); + if (previous != nullptr + && previous.get() != published.get()) + { + retiredRealtimeGraphSnapshots.push_back( + previous); + } + std::atomic_store_explicit( + &realtimeGraphSnapshot, + published, + std::memory_order_release); + realtimeGraphSnapshotForAudio.store( + published.get(), + std::memory_order_seq_cst); + } +} + +void TrackProcessor::resetFXContinuityStates() noexcept +{ + inputFXContinuity.fill({}); + trackFXContinuity.fill({}); + instrumentContinuity = {}; +} + +void TrackProcessor::refreshHostBypassDelayStorage() +{ + const auto snapshot = + std::atomic_load_explicit( + &realtimeGraphSnapshot, + std::memory_order_acquire); + if (snapshot == nullptr) + return; + + const int preparedBlockSize = + juce::jmax( + 1, + fxBypassDryBuffer.getNumSamples() + > 0 + ? fxBypassDryBuffer + .getNumSamples() + : kMinimumHostedPluginBlockSize); + const auto publishLatencyInPlace = + [preparedBlockSize] ( + const ProcessorSnapshot& processors, + const auto& storageArray) + { + if (processors.size() + > storageArray.size()) + return false; + + for (size_t index = 0; + index < processors.size(); + ++index) + { + const auto& processor = + processors[index]; + const auto& storage = + storageArray[index]; + if (processor == nullptr) + continue; + + const int reportedLatency = + juce::jmax( + 0, + processor + ->getLatencySamples()); + const int requiredCapacity = + juce::jmax( + kHostBypassLatencyHeadroomSamples, + reportedLatency) + + preparedBlockSize + 1; + if (storage == nullptr + || storage->processor + != processor.get() + || storage->ring + .getNumChannels() + < hostBypassDryChannels + || storage->ring + .getNumSamples() + < requiredCapacity) + { + return false; + } + + storage->publishedLatency.store( + reportedLatency, + std::memory_order_release); + } + return true; + }; + + // NAM sample-rate conversion changes the reported latency by only a few + // dozen samples, well inside the existing 4096-sample headroom. Updating + // these atomics avoids allocating and publishing a replacement graph + // snapshot, whose final shared_ptr release could otherwise occur on a + // 16-sample callback. + if (publishLatencyInPlace( + snapshot->inputFX, + snapshot->inputFXBypassDelay) + && publishLatencyInPlace( + snapshot->trackFX, + snapshot->trackFXBypassDelay)) + { + return; + } + + // Only genuinely larger plugin latency requires new delay storage. + const juce::ScopedLock processorCallbackGuard( + getCallbackLock()); + publishRealtimeStateSnapshots(); } void TrackProcessor::applyPluginAutomationForProcessor(juce::AudioProcessor* proc, bool isInputFX, int fxIndex, - double blockTimeSeconds) + double blockTimeSeconds, + const PluginAutomationRouteSnapshot* routes) { - if (proc == nullptr) - return; - - auto snapshot = std::atomic_load_explicit(&pluginAutomationSnapshot, std::memory_order_acquire); - if (!snapshot || snapshot->empty()) + if (proc == nullptr + || routes == nullptr + || routes->empty()) return; auto& params = proc->getParameters(); - if (params.isEmpty()) - return; - for (const auto& route : *snapshot) + for (const auto& route : *routes) { + const bool processorMatches = route != nullptr + && (route->targetProcessor != nullptr + ? route->targetProcessor == proc + : (route->isInputFX == isInputFX + && route->fxIndex == fxIndex)); if (!route - || route->isInputFX != isInputFX - || route->fxIndex != fxIndex - || route->automation == nullptr - || route->paramIndex < 0 - || route->paramIndex >= params.size()) + || ! processorMatches + || route->automation == nullptr) { continue; } - auto* param = params[route->paramIndex]; - if (param == nullptr) + // Legacy projects may still contain automation lanes for retired NAM + // controls. Ignore them permanently so old sessions cannot reactivate + // or repeatedly publish unsupported topology/state choices. + if (dynamic_cast<S13NAMRack*>(proc) != nullptr + && (route->builtInParamId == "transposeSemitones" + || route->builtInParamId == "inputMode")) + { continue; + } const float automatedValue = shouldApplyAutomation(*route->automation) ? route->automation->eval(blockTimeSeconds) : route->automation->getDefaultValue(); + if (! std::isfinite(automatedValue)) + continue; + const float clampedValue = juce::jlimit(0.0f, 1.0f, automatedValue); const float lastValue = route->lastAppliedValue.load(std::memory_order_relaxed); if (std::isfinite(lastValue) && std::abs(lastValue - clampedValue) <= 1.0e-6f) continue; - param->setValue(clampedValue); + if (route->builtInParamId.isNotEmpty()) + { + OpenStudioBuiltInAutomationDescriptor descriptor; + descriptor.minimum = route->builtInMinimum; + descriptor.maximum = route->builtInMaximum; + descriptor.discrete = route->builtInDiscrete; + descriptor.curve = route->builtInCurve; + auto rawValue = openStudioBuiltInNormalizedToValue( + descriptor, clampedValue); + if (route->builtInDiscrete) + rawValue = std::round(rawValue); + if (! setOpenStudioBuiltInParameterValue(proc, route->builtInParamId, rawValue)) + continue; + } + else + { + if (route->paramIndex < 0 || route->paramIndex >= params.size()) + continue; + auto* param = params[route->paramIndex]; + if (param == nullptr) + continue; + param->setValue(clampedValue); + } route->lastAppliedValue.store(clampedValue, std::memory_order_relaxed); } } @@ -731,7 +1787,125 @@ bool TrackProcessor::isMidiEffect() const double TrackProcessor::getTailLengthSeconds() const { - return 0.0; + double serialTailSeconds = 0.0; + const auto addProcessorTail = [&serialTailSeconds] (const juce::AudioProcessor* processor) + { + if (processor == nullptr) + return; + + const double processorTail = processor->getTailLengthSeconds(); + if (std::isfinite(processorTail) && processorTail > 0.0) + serialTailSeconds += processorTail; + }; + + for (int index = 0; index < static_cast<int>(inputFXPlugins.size()); ++index) + { + const auto& plugin = inputFXPlugins[static_cast<size_t>(index)]; + if (plugin && ! getInputFXBypassed(index)) + addProcessorTail(plugin.get()); + } + + addProcessorTail(instrumentPlugin.get()); + + for (int index = 0; index < static_cast<int>(trackFXPlugins.size()); ++index) + { + const auto& plugin = trackFXPlugins[static_cast<size_t>(index)]; + if (plugin && ! getTrackFXBypassed(index)) + addProcessorTail(plugin.get()); + } + + return serialTailSeconds; +} + +double TrackProcessor::getOfflineRenderTailLengthSeconds() const +{ + double serialTailSeconds = 0.0; + const auto automationSnapshot = std::atomic_load_explicit( + &pluginAutomationSnapshot, std::memory_order_acquire); + const auto getNAMTailAutomationModule = [] (const juce::String& parameterId) + { + if (parameterId == "delayEnabled" || parameterId == "delayMix" + || parameterId == "delayTimeMs" || parameterId == "delayFeedback" + || parameterId == "delayMod" || parameterId == "delayMode" + || parameterId == "delayPingPong" || parameterId == "delayTempoSync") + return static_cast<std::uint32_t>(S13NAMRack::tailAutomationDelay); + if (parameterId == "reverbEnabled" || parameterId == "reverbMix" + || parameterId == "reverbDecaySec" || parameterId == "reverbPreDelayMs" + || parameterId == "reverbTone" || parameterId == "reverbLowCutHz" + || parameterId == "reverbShimmer" || parameterId == "reverbVoice" + || parameterId == "reverbPad") + return static_cast<std::uint32_t>(S13NAMRack::tailAutomationReverb); + if (parameterId == "modulatorEnabled" || parameterId == "chorusMix" + || parameterId == "modulatorMode" || parameterId == "modulatorFeedback") + return static_cast<std::uint32_t>(S13NAMRack::tailAutomationModulator); + if (parameterId == "cabEnabled" + || parameterId == "cabRoomEnabled" + || parameterId == "cabRoomAmount" + || parameterId == "cabRoomWidth" + || parameterId == "cabDoublerEnabled" + || parameterId == "cabDoublerMix" + || parameterId == "cabDoublerDelayMs" + || parameterId == "cabDoublerSpread") + return static_cast<std::uint32_t>(S13NAMRack::tailAutomationCab); + return static_cast<std::uint32_t>(S13NAMRack::tailAutomationNone); + }; + const auto getTailAutomationMask = [&] (bool isInputFX, int fxIndex) + { + std::uint32_t mask = S13NAMRack::tailAutomationNone; + if (! automationSnapshot) + return mask; + for (const auto& route : *automationSnapshot) + { + if (route && route->isInputFX == isInputFX && route->fxIndex == fxIndex + && route->automation + && route->automation->shouldPlaybackForRead()) + { + const auto module = + getNAMTailAutomationModule(route->builtInParamId); + const bool hasRelevantPoints = + route->automation->getNumPoints() > 0; + if (hasRelevantPoints) + { + mask |= module; + } + } + } + return mask; + }; + const auto addProcessorTail = [&serialTailSeconds, &getTailAutomationMask] + (const juce::AudioProcessor* processor, bool isInputFX, int fxIndex) + { + if (processor == nullptr) + return; + + double processorTail = processor->getTailLengthSeconds(); + if (const auto* rack = dynamic_cast<const S13NAMRack*>(processor)) + processorTail = rack->getAutomatedTailLengthSeconds( + getTailAutomationMask(isInputFX, fxIndex)); + if (std::isfinite(processorTail) && processorTail > 0.0) + serialTailSeconds += processorTail; + }; + + for (int index = 0; index < static_cast<int>(inputFXPlugins.size()); ++index) + { + const auto& plugin = inputFXPlugins[static_cast<size_t>(index)]; + if (plugin && ! getInputFXBypassed(index)) + addProcessorTail(plugin.get(), true, index); + } + // Instruments do not use track/input FX automation route identities. + if (instrumentPlugin) + { + const double instrumentTail = instrumentPlugin->getTailLengthSeconds(); + if (std::isfinite(instrumentTail) && instrumentTail > 0.0) + serialTailSeconds += instrumentTail; + } + for (int index = 0; index < static_cast<int>(trackFXPlugins.size()); ++index) + { + const auto& plugin = trackFXPlugins[static_cast<size_t>(index)]; + if (plugin && ! getTrackFXBypassed(index)) + addProcessorTail(plugin.get(), false, index); + } + return serialTailSeconds; } int TrackProcessor::getNumPrograms() @@ -762,7 +1936,12 @@ void TrackProcessor::recomputePanGains() const float volumeGain = juce::Decibels::decibelsToGain(currentVolumeDb); float lGain = 1.0f; float rGain = 1.0f; - computePanLawGains(panLaw, currentPan, volumeGain, lGain, rGain); + computePanLawGains( + panLaw.load(std::memory_order_acquire), + currentPan, + volumeGain, + lGain, + rGain); cachedPanL.store(lGain, std::memory_order_relaxed); cachedPanR.store(rGain, std::memory_order_relaxed); @@ -807,11 +1986,18 @@ void TrackProcessor::changeProgramName (int index, const juce::String& newName) // empty, so getSampleRate()/getBlockSize() returned 0/0 and plugins ignored the call. // Now that we call prepareToPlay with valid values, we must restore the layout. static void preparePluginPreservingLayout(juce::AudioProcessor* plugin, double sampleRate, - int maxBlock, ProcessingPrecisionMode precisionMode) + int maxBlock, ProcessingPrecisionMode precisionMode, + int routedInputChannels = 2) { const juce::ScopedLock pluginCallbackGuard(plugin->getCallbackLock()); const int safeMaxBlock = getSafeHostedPluginBlockSize(maxBlock); + // NAM graph topology depends on the host route width. Publish it before + // prepare/reset so a newly inserted mono guitar Rack cannot begin in its + // default stereo topology and perform an avoidable first-callback handoff. + if (auto* const rack = dynamic_cast<S13NAMRack*>(plugin)) + rack->setRoutedInputChannelCount(routedInputChannels); + if (plugin->supportsDoublePrecisionProcessing()) { plugin->setProcessingPrecision( @@ -848,10 +2034,28 @@ static ProcessingPrecisionMode resolvePluginPrecisionMode(ProcessingPrecisionMod void TrackProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) { + realtimeFXTailSampleRateHz.store( + juce::roundToInt(juce::jlimit(8000.0, 384000.0, + sampleRate > 0.0 ? sampleRate : 44100.0)), + std::memory_order_release); // Pre-allocate FX processing buffer with enough channels for complex plugins. // Use the actual device block size here — the buffer just needs to hold one callback. fxProcessBuffer.setSize(kMaxFXChannels, samplesPerBlock); fxProcessBufferDouble.setSize(kMaxFXChannels, samplesPerBlock); + fxBypassDryBuffer.setSize(kMaxFXChannels, samplesPerBlock); + constexpr double hostBypassRampSeconds = 0.020; + fxBypassRampStep = 1.0f + / static_cast<float>(juce::jmax( + 1, + juce::roundToInt( + juce::jmax(1.0, sampleRate) + * hostBypassRampSeconds))); + constexpr double continuityRampSeconds = 0.008; + fxContinuityRampSamples = juce::jmax( + 1, + juce::roundToInt( + juce::jmax(1.0, sampleRate) + * continuityRampSeconds)); // Prepare PDC delay line { @@ -860,9 +2064,25 @@ void TrackProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) spec.maximumBlockSize = static_cast<juce::uint32>(samplesPerBlock); spec.numChannels = 2; pdcDelayLine.prepare(spec); - const int preparedPdcDelaySamples = pdcDelaySamples.load(std::memory_order_relaxed); - if (preparedPdcDelaySamples > 0) - pdcDelayLine.setDelay(static_cast<float>(preparedPdcDelaySamples)); + pdcCurrentDelaySamples = + juce::jmax( + 0, + pdcDelaySamples.load( + std::memory_order_relaxed)); + pdcTargetDelaySamples = + pdcCurrentDelaySamples; + pdcPendingDelaySamples = + pdcCurrentDelaySamples; + pdcTransitionSamplesRemaining = 0; + pdcTransitionSamplesTotal = + juce::jmax( + 1, + juce::roundToInt( + juce::jmax(1.0, sampleRate) + * 0.020)); + pdcDelayLine.setDelay( + static_cast<float>( + pdcCurrentDelaySamples)); } // Prepare plugins with the actual device block size so realtime hosting @@ -878,7 +2098,8 @@ void TrackProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) { preparePluginPreservingLayout(plugin.get(), sampleRate, pluginMaxBlock, resolvePluginPrecisionMode(processingPrecisionMode, - getInputFXPrecisionOverride(index))); + getInputFXPrecisionOverride(index)), + inputChannelCount.load(std::memory_order_acquire)); plugin->reset(); } } @@ -892,7 +2113,8 @@ void TrackProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) const int pluginBlockSize = pluginMaxBlock; preparePluginPreservingLayout(plugin.get(), sampleRate, pluginBlockSize, resolvePluginPrecisionMode(processingPrecisionMode, - getTrackFXPrecisionOverride(index))); + getTrackFXPrecisionOverride(index)), + inputChannelCount.load(std::memory_order_acquire)); plugin->reset(); } } @@ -907,19 +2129,147 @@ void TrackProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) } // Prepare channel strip EQ + channelStripEQ.setPowerEnabled( + channelStripEQEnabled.load(std::memory_order_acquire)); channelStripEQ.prepareToPlay(sampleRate, samplesPerBlock); // Pre-allocate pre-fader buffer for send routing (2-channel stereo) preFaderBuffer.setSize(2, samplesPerBlock); automationGainBuffer.setSize(8, samplesPerBlock); realtimeFallbackBuffer.setSize(2, samplesPerBlock); - midiOutputResetBuffer.ensureSize(512); + publishRealtimeStateSnapshots(); + resetFXContinuityStates(); + invalidatePluginAutomationCache(); + refreshRealtimeFXTailBudgetOnControlThread(); + realtimeFXTailActive.store(false, std::memory_order_release); + realtimeFXTailResetPending.store(false, std::memory_order_release); + realtimeFXTailHardSamplesRemaining = 0; + realtimeFXTailMinimumSamplesRemaining = 0; + realtimeFXTailQuietSamples = 0; + realtimeFXTailLastPublishedBudgetSamples = 0; + realtimeFXPreviousBlockHadInput = false; } void TrackProcessor::releaseResources() { } +void TrackProcessor::refreshRealtimeFXTailBudgetOnControlThread() +{ + double reportedTailSeconds = 0.0; + try + { + reportedTailSeconds = getTailLengthSeconds(); + } + catch (...) + { + // A hosted plugin must not be able to disable bounded tail servicing. + // The conservative fallback below is long enough for the built-in rack. + reportedTailSeconds = 30.0; + } + + if (! std::isfinite(reportedTailSeconds) || reportedTailSeconds < 0.0) + reportedTailSeconds = 30.0; + + const double safeSampleRate = static_cast<double>( + realtimeFXTailSampleRateHz.load(std::memory_order_acquire)); + // Keep the service finite without imposing an arbitrary musical limit. + // The callback countdown is an int, so its representable duration at the + // current sample rate is the only hard cap. This covers the NAM Rack's + // sparse 10-BPM synced repeats (and long standalone built-in delays) while + // still protecting the realtime path from a malformed hosted tail report. + const double maximumCountdownSeconds = + static_cast<double>(std::numeric_limits<int>::max() - 1) + / juce::jmax(1.0, safeSampleRate); + const double boundedTailSeconds = juce::jlimit( + 0.0, + juce::jmax(0.0, + maximumCountdownSeconds - kRealtimeFXTailSafetySeconds), + reportedTailSeconds); + const double budgetSeconds = juce::jlimit( + kRealtimeFXTailQuietWindowSeconds, + maximumCountdownSeconds, + boundedTailSeconds + kRealtimeFXTailSafetySeconds); + // A quiet window is not proof that a sparse delay has ended. Do not allow + // the quiet detector to finish servicing until the processor's complete + // declared tail horizon has elapsed. + const double minimumDrainSeconds = boundedTailSeconds; + + realtimeFXTailBudgetSamples.store( + juce::jmax(1, juce::roundToInt(budgetSeconds * safeSampleRate)), + std::memory_order_release); + realtimeFXTailMinimumDrainSamples.store( + juce::jmax(0, juce::roundToInt(minimumDrainSeconds * safeSampleRate)), + std::memory_order_release); +} + +void TrackProcessor::resetExpiredRealtimeFXTailOnControlThread() +{ + if (! realtimeFXTailResetPending.load(std::memory_order_acquire)) + return; + + const auto requestedGeneration = + realtimeFXTailResetGeneration.load(std::memory_order_acquire); + if (realtimeFXTailActivityGeneration.load(std::memory_order_acquire) + != requestedGeneration) + { + realtimeFXTailResetPending.store(false, std::memory_order_release); + return; + } + + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + const auto resetProcessor = [&] (const ProcessorPtr& processor) + { + if (processor == nullptr + || realtimeFXTailActivityGeneration.load(std::memory_order_acquire) + != requestedGeneration) + { + return; + } + + // Arbitrary hosted reset() implementations stay off the callback. Each + // FX callback uses ScopedTryLock, so it falls back to latency-aligned + // dry audio instead of ever waiting for this control-thread reset. + const juce::ScopedLock processorGuard(processor->getCallbackLock()); + if (realtimeFXTailActivityGeneration.load(std::memory_order_acquire) + == requestedGeneration) + { + processor->reset(); + } + }; + + if (graph != nullptr) + { + for (const auto& processor : graph->inputFX) + resetProcessor(processor); + resetProcessor(graph->instrument); + for (const auto& processor : graph->trackFX) + resetProcessor(processor); + } + + if (realtimeFXTailActivityGeneration.load(std::memory_order_acquire) + == requestedGeneration) + { + realtimeFXTailActive.store(false, std::memory_order_release); + realtimeFXTailResetPending.store(false, std::memory_order_release); + } +} + +void TrackProcessor::timerCallback() +{ + if (realtimeFXTailActive.load(std::memory_order_acquire) + && ! realtimeFXTailResetPending.load(std::memory_order_acquire)) + { + // Tail controls (especially NAM Rack decay/delay) can change without a + // graph publication. Query them on the control thread, never in the + // 8/16-sample callback. + refreshRealtimeFXTailBudgetOnControlThread(); + } + + resetExpiredRealtimeFXTailOnControlThread(); +} + bool TrackProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const { juce::ignoreUnused (layouts); @@ -939,30 +2289,226 @@ bool TrackProcessor::tryProcessBlock(juce::AudioBuffer<float>& buffer, juce::Mid void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages) { + const int activeARAFXIndexForBlock = + araFXIndexForRealtime.load( + std::memory_order_acquire); + auto* const activeARAControllerForBlock = + activeARAFXIndexForBlock >= 0 + ? araController.get() + : nullptr; // Only time the track when ARA diagnostics are enabled and an ARA plugin is active. // QueryPerformanceCounter is cheap but not free — at 32-sample blocks this fires // 1500×/sec, so we avoid it for non-ARA tracks (e.g. Amplitube, S13 FX). const bool isARATrack = kEnableARADebugDiagnostics - && araController != nullptr - && araController->isActive(); + && activeARAControllerForBlock != nullptr; const double trackProcessStartMs = isARATrack ? juce::Time::getMillisecondCounterHiRes() : 0.0; juce::ScopedNoDenormals noDenormals; auto totalNumInputChannels = getTotalNumInputChannels(); auto totalNumOutputChannels = getTotalNumOutputChannels(); const auto currentTrackType = trackType.load(std::memory_order_acquire); - auto inputFXSnapshot = std::atomic_load_explicit(&realtimeInputFXSnapshot, std::memory_order_acquire); - auto trackFXSnapshot = std::atomic_load_explicit(&realtimeTrackFXSnapshot, std::memory_order_acquire); - auto inputFXBypassSnapshot = std::atomic_load_explicit(&realtimeInputFXBypassSnapshot, std::memory_order_acquire); - auto trackFXBypassSnapshot = std::atomic_load_explicit(&realtimeTrackFXBypassSnapshot, std::memory_order_acquire); - auto inputFXPrecisionOverrideSnapshot = std::atomic_load_explicit(&realtimeInputFXPrecisionOverrideSnapshot, std::memory_order_acquire); - auto trackFXPrecisionOverrideSnapshot = std::atomic_load_explicit(&realtimeTrackFXPrecisionOverrideSnapshot, std::memory_order_acquire); - auto instrumentSnapshot = std::atomic_load_explicit(&realtimeInstrumentSnapshot, std::memory_order_acquire); - auto sidechainSnapshot = std::atomic_load_explicit(&realtimeSidechainSnapshot, std::memory_order_acquire); - auto sendSnapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); + // Avoid MSVC's process-wide atomic<shared_ptr> spin lock in the audio + // callback. Control-side publication retains replaced immutable graphs + // until this reader epoch has drained. + const ScopedTrackRealtimeReader graphReadGuard( + realtimeGraphAudioReaders); + const auto* const graphSnapshot = + realtimeGraphSnapshotForAudio.load( + std::memory_order_seq_cst); + const uint64 graphGeneration = + graphSnapshot != nullptr ? graphSnapshot->generation : 0; + const auto* const inputFXSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->inputFX : nullptr; + const auto* const trackFXSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->trackFX : nullptr; + const auto* const inputFXBypassSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->inputFXBypass : nullptr; + const auto* const trackFXBypassSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->trackFXBypass : nullptr; + const auto* const inputFXPrecisionOverrideSnapshot = + graphSnapshot != nullptr + ? &graphSnapshot->inputFXPrecisionOverrides + : nullptr; + const auto* const trackFXPrecisionOverrideSnapshot = + graphSnapshot != nullptr + ? &graphSnapshot->trackFXPrecisionOverrides + : nullptr; + auto* const instrumentSnapshot = + graphSnapshot != nullptr ? graphSnapshot->instrument.get() : nullptr; + const bool hasPluginAutomationRoutesForBlock = + hasPublishedPluginAutomationRoutes.load( + std::memory_order_acquire); + const ScopedTrackRealtimeReader auxReadGuard( + realtimeAuxAudioReaders, + hasPluginAutomationRoutesForBlock + || currentTrackType == TrackType::Instrument); + const auto* const pluginAutomationRoutesForBlock = + hasPluginAutomationRoutesForBlock + ? pluginAutomationSnapshotForAudio.load( + std::memory_order_seq_cst) + : nullptr; + const auto* const sidechainSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->sidechainSources : nullptr; + const auto* const sendSnapshot = + graphSnapshot != nullptr ? &graphSnapshot->sends : nullptr; + const auto* const inputFXBypassDelaySnapshot = + graphSnapshot != nullptr + ? &graphSnapshot->inputFXBypassDelay + : nullptr; + const auto* const trackFXBypassDelaySnapshot = + graphSnapshot != nullptr + ? &graphSnapshot->trackFXBypassDelay + : nullptr; const bool instrumentForceFloat = instrumentForceFloatOverride.load(std::memory_order_acquire); const double blockTimeSeconds = this->blockStartTimeSeconds; + const auto hasEnabledProcessor = [] ( + const ProcessorSnapshot* processors, + const BypassSnapshot* bypassState) noexcept + { + if (processors == nullptr) + return false; + + for (int index = 0; + index < static_cast<int>(processors->size()); + ++index) + { + if ((*processors)[static_cast<size_t>(index)] == nullptr) + continue; + const auto bypassIt = bypassState != nullptr + ? bypassState->find(index) + : BypassSnapshot::const_iterator {}; + if (bypassState == nullptr + || bypassIt == bypassState->end() + || ! bypassIt->second) + { + return true; + } + } + return false; + }; + const bool hasEnabledRealtimeFX = + currentTrackType == TrackType::Audio + && (hasEnabledProcessor(inputFXSnapshot, inputFXBypassSnapshot) + || hasEnabledProcessor(trackFXSnapshot, trackFXBypassSnapshot)); + bool hasExternalAudioInput = false; + if (hasEnabledRealtimeFX) + { + for (int channel = 0; channel < buffer.getNumChannels(); ++channel) + { + const float inputPeak = buffer.getMagnitude( + channel, 0, buffer.getNumSamples()); + if (std::isfinite(inputPeak) && inputPeak > 0.0f) + { + hasExternalAudioInput = true; + break; + } + } + } + + if (! hasEnabledRealtimeFX) + { + realtimeFXTailActive.store(false, std::memory_order_release); + realtimeFXTailResetPending.store(false, std::memory_order_release); + realtimeFXTailHardSamplesRemaining = 0; + realtimeFXTailMinimumSamplesRemaining = 0; + realtimeFXTailQuietSamples = 0; + realtimeFXTailLastPublishedBudgetSamples = 0; + realtimeFXPreviousBlockHadInput = false; + } + else if (hasExternalAudioInput) + { + if (! realtimeFXPreviousBlockHadInput) + { + realtimeFXTailActivityGeneration.fetch_add( + 1, std::memory_order_acq_rel); + } + realtimeFXPreviousBlockHadInput = true; + realtimeFXTailActive.store(true, std::memory_order_release); + realtimeFXTailResetPending.store(false, std::memory_order_release); + const int publishedBudget = + realtimeFXTailBudgetSamples.load(std::memory_order_acquire); + realtimeFXTailHardSamplesRemaining = juce::jmax( + buffer.getNumSamples(), + publishedBudget); + realtimeFXTailMinimumSamplesRemaining = juce::jlimit( + 0, + realtimeFXTailHardSamplesRemaining, + realtimeFXTailMinimumDrainSamples.load(std::memory_order_acquire)); + realtimeFXTailQuietSamples = 0; + realtimeFXTailLastPublishedBudgetSamples = publishedBudget; + } + else + { + realtimeFXPreviousBlockHadInput = false; + } + + const bool isRealtimeFXTailDrainBlock = + hasEnabledRealtimeFX + && ! hasExternalAudioInput + && realtimeFXTailActive.load(std::memory_order_acquire) + && ! realtimeFXTailResetPending.load(std::memory_order_acquire); + const auto finishRealtimeFXTailDrain = [&] (float outputPeak) noexcept + { + if (! isRealtimeFXTailDrainBlock) + return; + + const int blockSamples = juce::jmax(0, buffer.getNumSamples()); + const int publishedBudget = + realtimeFXTailBudgetSamples.load(std::memory_order_acquire); + if (publishedBudget > realtimeFXTailLastPublishedBudgetSamples) + { + // A built-in processor can publish a longer live/frozen tail after + // the external input has stopped. Adopt that increase once; an + // unchanged fixed plugin report must not refresh the countdown on + // every timer tick. + realtimeFXTailHardSamplesRemaining = juce::jmax( + realtimeFXTailHardSamplesRemaining, publishedBudget); + realtimeFXTailMinimumSamplesRemaining = juce::jmax( + realtimeFXTailMinimumSamplesRemaining, + realtimeFXTailMinimumDrainSamples.load( + std::memory_order_acquire)); + } + realtimeFXTailLastPublishedBudgetSamples = publishedBudget; + realtimeFXTailHardSamplesRemaining = juce::jmax( + 0, realtimeFXTailHardSamplesRemaining - blockSamples); + realtimeFXTailMinimumSamplesRemaining = juce::jmax( + 0, realtimeFXTailMinimumSamplesRemaining - blockSamples); + + if (realtimeFXTailMinimumSamplesRemaining <= 0) + { + if (std::isfinite(outputPeak) + && outputPeak < kRealtimeFXTailQuietPeak) + { + realtimeFXTailQuietSamples = juce::jmin( + std::numeric_limits<int>::max() - blockSamples, + realtimeFXTailQuietSamples) + blockSamples; + } + else + { + realtimeFXTailQuietSamples = 0; + } + } + + const int quietWindowSamples = juce::jmax( + 1, + juce::roundToInt( + static_cast<double>(realtimeFXTailSampleRateHz.load( + std::memory_order_relaxed)) + * kRealtimeFXTailQuietWindowSeconds)); + if (realtimeFXTailHardSamplesRemaining <= 0 + || realtimeFXTailQuietSamples >= quietWindowSamples) + { + const auto generation = + realtimeFXTailActivityGeneration.load(std::memory_order_acquire); + realtimeFXTailResetGeneration.store( + generation, std::memory_order_release); + realtimeFXTailResetPending.store(true, std::memory_order_release); + } + }; bool hasTrackBuiltInInstrument = false; - if (trackFXSnapshot) + // Audio tracks cannot host OpenStudio's built-in instrument fallback. + // Avoid calling getName() on every FX here: JUCE returns String by value, + // which can allocate on the realtime thread even for a literal name. + if (currentTrackType == TrackType::Instrument && trackFXSnapshot) { for (const auto& plugin : *trackFXSnapshot) { @@ -974,9 +2520,11 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc } } - if (araController != nullptr && araController->isActive()) - araController->updateTransportDebugState(araTransportPlayingDebugState.load(std::memory_order_acquire), - blockTimeSeconds); + if (activeARAControllerForBlock != nullptr) + activeARAControllerForBlock->updateTransportDebugState( + araTransportPlayingDebugState.load( + std::memory_order_acquire), + blockTimeSeconds); // Safety: only clear channels that actually exist in the buffer int bufferChannels = buffer.getNumChannels(); @@ -991,18 +2539,119 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc { buffer.clear(); currentRMS = 0.0f; + finishRealtimeFXTailDrain(0.0f); return; } - // Apply Plugin Delay Compensation (PDC) before FX chains + // Apply Plugin Delay Compensation (PDC) before FX chains. A model-rate + // change can alter another track's compensation by tens of samples. A + // hard DelayLine::setDelay() repeats or drops that history at one sample + // and sounds like a click, so crossfade the two already-warm integer taps. if (pdcDelayDirty.exchange(false, std::memory_order_acq_rel)) - pdcDelayLine.setDelay(static_cast<float>(pdcDelaySamples.load(std::memory_order_relaxed))); + { + pdcPendingDelaySamples = + juce::jlimit( + 0, + pdcDelayLine + .getMaximumDelayInSamples(), + pdcDelaySamples.load( + std::memory_order_relaxed)); + } - if (pdcDelaySamples.load(std::memory_order_relaxed) > 0) + // Process even at zero delay so the ring buffer always contains current + // audio. A later 0 -> positive PDC change then cannot replay stale samples. + const auto beginPendingPDCTransition = + [this] () noexcept { - juce::dsp::AudioBlock<float> block(buffer); - juce::dsp::ProcessContextReplacing<float> context(block); - pdcDelayLine.process(context); + if (pdcTransitionSamplesRemaining <= 0 + && pdcPendingDelaySamples + != pdcCurrentDelaySamples) + { + pdcTargetDelaySamples = + pdcPendingDelaySamples; + pdcTransitionSamplesRemaining = + pdcTransitionSamplesTotal; + } + }; + beginPendingPDCTransition(); + for (int sample = 0; + sample < buffer.getNumSamples(); + ++sample) + { + float transitionMix = 0.0f; + const bool transitioning = + pdcTransitionSamplesRemaining > 0; + if (transitioning) + { + const float linearProgress = + 1.0f + - static_cast<float>( + pdcTransitionSamplesRemaining + - 1) + / static_cast<float>( + pdcTransitionSamplesTotal); + transitionMix = + linearProgress + * linearProgress + * (3.0f + - 2.0f + * linearProgress); + } + + for (int channel = 0; + channel < bufferChannels; + ++channel) + { + const float input = + buffer.getSample( + channel, sample); + pdcDelayLine.pushSample( + channel, input); + if (transitioning) + { + const float previousTap = + pdcDelayLine.popSample( + channel, + static_cast<float>( + pdcCurrentDelaySamples), + false); + const float nextTap = + pdcDelayLine.popSample( + channel, + static_cast<float>( + pdcTargetDelaySamples), + true); + buffer.setSample( + channel, + sample, + previousTap + + (nextTap - previousTap) + * transitionMix); + } + else + { + buffer.setSample( + channel, + sample, + pdcDelayLine.popSample( + channel)); + } + } + + if (transitioning) + { + --pdcTransitionSamplesRemaining; + if (pdcTransitionSamplesRemaining + <= 0) + { + pdcCurrentDelaySamples = + pdcTargetDelaySamples; + pdcDelayLine.setDelay( + static_cast<float>( + pdcCurrentDelaySamples)); + beginPendingPDCTransition(); + } + } } bool hasAnyFX = (inputFXSnapshot && !inputFXSnapshot->empty()) @@ -1063,30 +2712,185 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc " bufferChannels=" + juce::String(bufferChannels) + " path=" + juce::String(pluginChannels == bufferChannels ? "DIRECT" : "EXPANDED")); } - } + } + + const auto resolveFXContinuity = + [&] (juce::AudioProcessor* proc, + bool isInputFXChain, + int fxIndex, + bool bypassed) -> FXContinuityState* + { + FXContinuityState* continuity = nullptr; + if (fxIndex < 0) + { + continuity = &instrumentContinuity; + } + else if (static_cast<size_t>(fxIndex) < maxRealtimeFXContinuitySlots) + { + continuity = isInputFXChain + ? &inputFXContinuity[static_cast<size_t>(fxIndex)] + : &trackFXContinuity[static_cast<size_t>(fxIndex)]; + } + + if (continuity == nullptr) + return nullptr; + + if (continuity->processor != proc) + { + *continuity = {}; + continuity->processor = proc; + continuity->hostBypassWetMix = + bypassed ? 0.0f : 1.0f; + continuity->targetBypassed = bypassed; + } + else if (continuity->targetBypassed != bypassed) + { + // Re-enabling follows a message-thread reset of the frozen + // processor. Bridge the first fresh block from the last audible + // endpoint even when the user reverses direction mid-fade. + if (! bypassed) + continuity->skippedLastBlock = true; + continuity->targetBypassed = bypassed; + } + continuity->graphGeneration = graphGeneration; + return continuity; + }; + + const auto scheduleEndpointCorrection = + [&] (FXContinuityState* continuity) + { + if (continuity == nullptr + || !continuity->valid + || buffer.getNumSamples() <= 0) + { + return; + } + + const int channels = juce::jmin(2, buffer.getNumChannels()); + for (int channel = 0; channel < channels; ++channel) + { + const float correction = + continuity->lastOutput[static_cast<size_t>(channel)] + - buffer.getSample(channel, 0); + continuity->endpointCorrection[ + static_cast<size_t>(channel)] = correction; + continuity->endpointCorrectionStep[ + static_cast<size_t>(channel)] = + correction + / static_cast<float>( + fxContinuityRampSamples); + } + continuity->endpointCorrectionSamplesRemaining = + fxContinuityRampSamples; + }; + + const auto applyEndpointCorrection = + [&] (FXContinuityState* continuity) + { + if (continuity == nullptr + || continuity + ->endpointCorrectionSamplesRemaining <= 0) + return; + + const int channels = juce::jmin( + 2, buffer.getNumChannels()); + const int samples = buffer.getNumSamples(); + for (int sample = 0; + sample < samples + && continuity + ->endpointCorrectionSamplesRemaining > 0; + ++sample) + { + for (int channel = 0; + channel < channels; + ++channel) + { + const auto index = + static_cast<size_t>(channel); + buffer.addSample( + channel, + sample, + continuity->endpointCorrection[index]); + continuity->endpointCorrection[index] -= + continuity->endpointCorrectionStep[index]; + } + --continuity + ->endpointCorrectionSamplesRemaining; + } + + if (continuity + ->endpointCorrectionSamplesRemaining <= 0) + { + continuity->endpointCorrection = { + 0.0f, 0.0f + }; + continuity->endpointCorrectionStep = { + 0.0f, 0.0f + }; + } + }; + + const auto rememberOutputEndpoint = + [&] (FXContinuityState* continuity) + { + if (continuity == nullptr || buffer.getNumSamples() <= 0) + return; + + const int lastSample = buffer.getNumSamples() - 1; + const int channels = juce::jmin(2, buffer.getNumChannels()); + for (int channel = 0; channel < channels; ++channel) + { + continuity->lastOutput[static_cast<size_t>(channel)] = + buffer.getSample(channel, lastSample); + } + continuity->valid = channels > 0; + }; - // Channel-safe FX processing helper - auto safeProcessFX = [&](juce::AudioProcessor* proc, bool forceFloat, bool isInputFXChain, int fxIndex) + // Channel-safe raw FX processing helper. Host-bypass mixing and endpoint + // bookkeeping are deliberately outside this function so the sidechain and + // ordinary paths share exactly the same transition behaviour. + auto safeProcessFX = + [&] (juce::AudioProcessor* proc, + bool forceFloat, + bool isInputFXChain, + int fxIndex) -> bool { juce::ScopedTryLock pluginProcessLock(proc->getCallbackLock()); if (!pluginProcessLock.isLocked()) { pluginBusySkipCount.fetch_add(1, std::memory_order_relaxed); - return; + return false; + } + + if (auto* const rack = + dynamic_cast<S13NAMRack*>(proc)) + { + rack->setRoutedInputChannelCount( + inputChannelCount.load( + std::memory_order_acquire)); } - applyPluginAutomationForProcessor(proc, isInputFXChain, fxIndex, blockTimeSeconds); + applyPluginAutomationForProcessor( + proc, + isInputFXChain, + fxIndex, + blockTimeSeconds, + pluginAutomationRoutesForBlock); // Compute isARAProcessor first so we can gate expensive QPC calls on it. // For non-ARA plugins (Amplitube, S13 FX, etc.) all timing overhead is skipped. int pluginChannels = juce::jmax(proc->getTotalNumInputChannels(), proc->getTotalNumOutputChannels()); - const bool isARAProcessor = araController != nullptr - && araController->isActive() - && araFXIndex >= 0 - && trackFXSnapshot - && araFXIndex < static_cast<int>(trackFXSnapshot->size()) - && (*trackFXSnapshot)[static_cast<size_t>(araFXIndex)].get() == proc; + const bool isARAProcessor = + activeARAControllerForBlock != nullptr + && trackFXSnapshot + && activeARAFXIndexForBlock + < static_cast<int>( + trackFXSnapshot->size()) + && (*trackFXSnapshot)[ + static_cast<size_t>( + activeARAFXIndexForBlock)] + .get() == proc; const double envelopeStartMs = isARAProcessor ? juce::Time::getMillisecondCounterHiRes() : 0.0; const bool useDoublePrecision = processingPrecisionMode == ProcessingPrecisionMode::Hybrid64 @@ -1111,9 +2915,12 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc if (lastSlowRun != playbackRun) { araLastSlowLogPlaybackRun.store(playbackRun, std::memory_order_release); - const auto snapshot = araController->getDebugSnapshot(); + const auto snapshot = + activeARAControllerForBlock + ->getDebugSnapshot(); logToDisk("ARA session slow-block: trackId=" + araDebugTrackId - + " fxIndex=" + juce::String(araFXIndex) + + " fxIndex=" + juce::String( + activeARAFXIndexForBlock) + " plugin=" + proc->getName() + " callback=" + juce::String(static_cast<juce::int64>(currentARAProcessDebugInfo.callbackCounter)) + " firstCallbackAfterTransportStart=" + juce::String(currentARAProcessDebugInfo.firstCallbackAfterTransportStart ? "true" : "false") @@ -1265,11 +3072,383 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc } for (const auto metadata : midiMessages) markActiveMIDINoteState(metadata.getMessage()); + return true; + }; + + const auto canUseBypassDryBuffer = [&] + { + return buffer.getNumChannels() + <= fxBypassDryBuffer.getNumChannels() + && buffer.getNumSamples() + <= fxBypassDryBuffer.getNumSamples(); + }; + + const auto resolveBypassDelayStorage = + [&] (bool isInputFXChain, + int fxIndex) -> FXBypassDelayStorage* + { + if (fxIndex < 0 + || static_cast<size_t>(fxIndex) + >= maxRealtimeFXContinuitySlots) + return nullptr; + + const auto* snapshot = isInputFXChain + ? inputFXBypassDelaySnapshot + : trackFXBypassDelaySnapshot; + if (snapshot == nullptr) + return nullptr; + return (*snapshot)[static_cast<size_t>(fxIndex)].get(); + }; + + const auto prepareLatencyAlignedDry = + [&] (FXBypassDelayStorage* storage, + const juce::AudioProcessor& processor, + bool writeDryOutput, + bool advanceHistory) + { + const int channels = buffer.getNumChannels(); + const int samples = buffer.getNumSamples(); + if (storage == nullptr + || storage->processor != &processor + || storage->ring.getNumChannels() < channels + || storage->ring.getNumSamples() <= 0) + { + if (writeDryOutput) + { + for (int channel = 0; + channel < channels; + ++channel) + { + fxBypassDryBuffer.copyFrom( + channel, + 0, + buffer, + channel, + 0, + samples); + } + } + return false; + } + + const int capacity = + storage->ring.getNumSamples(); + const int reportedLatency = juce::jlimit( + 0, + capacity - 1, + storage->publishedLatency.load( + std::memory_order_acquire)); + if (! storage->latencyInitialised) + { + storage->currentLatency = reportedLatency; + storage->targetLatency = reportedLatency; + storage->latencyRampRemaining = 0; + storage->latencyRampLength = 0; + storage->latencyInitialised = true; + } + else if (reportedLatency + != storage->targetLatency) + { + storage->targetLatency = reportedLatency; + if (writeDryOutput + && storage->currentLatency + != storage->targetLatency) + { + storage->latencyRampLength = + fxContinuityRampSamples; + storage->latencyRampRemaining = + fxContinuityRampSamples; + } + else + { + storage->currentLatency = + storage->targetLatency; + storage->latencyRampRemaining = 0; + storage->latencyRampLength = 0; + } + } + else if (! writeDryOutput + && storage->latencyRampRemaining > 0) + { + // No dry signal is currently audible, so adopt a latency update + // immediately while continuing to keep the history ring warm. + storage->currentLatency = + storage->targetLatency; + storage->latencyRampRemaining = 0; + storage->latencyRampLength = 0; + } + + auto writePosition = storage->writePosition; + if (! advanceHistory) + { + writePosition -= samples % capacity; + if (writePosition < 0) + writePosition += capacity; + } + for (int sample = 0; sample < samples; ++sample) + { + if (advanceHistory) + { + for (int channel = 0; + channel < channels; + ++channel) + { + storage->ring.setSample( + channel, + writePosition, + buffer.getSample(channel, sample)); + } + } + + if (writeDryOutput) + { + int currentRead = + writePosition + - storage->currentLatency; + if (currentRead < 0) + currentRead += capacity; + int targetRead = + writePosition + - storage->targetLatency; + if (targetRead < 0) + targetRead += capacity; + const float latencyMix = + storage->latencyRampRemaining > 0 + && storage->latencyRampLength > 0 + ? 1.0f + - static_cast<float>( + storage + ->latencyRampRemaining) + / static_cast<float>( + storage + ->latencyRampLength) + : 1.0f; + for (int channel = 0; + channel < channels; + ++channel) + { + const float currentDry = + storage->ring.getSample( + channel, currentRead); + const float targetDry = + storage->ring.getSample( + channel, targetRead); + fxBypassDryBuffer.setSample( + channel, + sample, + currentDry + + (targetDry - currentDry) + * latencyMix); + } + } + + ++writePosition; + if (writePosition >= capacity) + writePosition = 0; + if (writeDryOutput + && storage->latencyRampRemaining > 0) + { + --storage->latencyRampRemaining; + if (storage->latencyRampRemaining == 0) + { + storage->currentLatency = + storage->targetLatency; + storage->latencyRampLength = 0; + } + } + } + if (advanceHistory) + storage->writePosition = writePosition; + return true; + }; + + const auto applyHostBypassCrossfade = + [&] (FXContinuityState& continuity, + bool bypassed) + { + const float target = bypassed ? 0.0f : 1.0f; + const int channels = buffer.getNumChannels(); + const int samples = buffer.getNumSamples(); + auto wetMix = continuity.hostBypassWetMix; + for (int sample = 0; sample < samples; ++sample) + { + wetMix = target < wetMix + ? juce::jmax(target, wetMix - fxBypassRampStep) + : juce::jmin(target, wetMix + fxBypassRampStep); + for (int channel = 0; channel < channels; ++channel) + { + const float dry = + fxBypassDryBuffer.getSample(channel, sample); + const float wet = buffer.getSample(channel, sample); + buffer.setSample( + channel, + sample, + dry + (wet - dry) * wetMix); + } + } + continuity.hostBypassWetMix = wetMix; + }; + + const auto finishFXSlot = + [&] (FXContinuityState* continuity, + bool bypassed, + bool processed, + bool dryInputCaptured) + { + if (continuity == nullptr) + return; + + if (processed && dryInputCaptured) + applyHostBypassCrossfade(*continuity, bypassed); + else if (! processed && bypassed) + { + // A busy processor already leaves the dry input in place. Still + // advance a bypass request so it reaches the zero-CPU steady state. + continuity->hostBypassWetMix = juce::jmax( + 0.0f, + continuity->hostBypassWetMix + - fxBypassRampStep + * static_cast<float>( + buffer.getNumSamples())); + } + + if (processed) + { + if (continuity->skippedLastBlock) + scheduleEndpointCorrection(continuity); + applyEndpointCorrection(continuity); + rememberOutputEndpoint(continuity); + continuity->skippedLastBlock = false; + } + else + { + if (! continuity->skippedLastBlock) + scheduleEndpointCorrection(continuity); + applyEndpointCorrection(continuity); + rememberOutputEndpoint(continuity); + continuity->skippedLastBlock = true; + } + }; + + const auto processFXWithHostBypass = + [&] (juce::AudioProcessor* proc, + bool forceFloat, + bool isInputFXChain, + int fxIndex, + bool bypassed) + { + auto* continuity = resolveFXContinuity( + proc, isInputFXChain, fxIndex, bypassed); + if (continuity == nullptr && bypassed) + return; + + const bool transitioning = + continuity != nullptr + && std::abs( + continuity->hostBypassWetMix + - (bypassed ? 0.0f : 1.0f)) > 1.0e-6f; + auto* bypassDelay = resolveBypassDelayStorage( + isInputFXChain, fxIndex); + const bool canWriteDry = + canUseBypassDryBuffer(); + const bool writeDryOutput = + canWriteDry + && fxIndex >= 0 + && (transitioning || bypassed); + if (fxIndex >= 0) + { + prepareLatencyAlignedDry( + bypassDelay, + *proc, + writeDryOutput, + true); + } + + if (continuity != nullptr + && bypassed + && continuity->hostBypassWetMix <= 0.0f) + { + if (writeDryOutput) + { + for (int channel = 0; + channel < buffer.getNumChannels(); + ++channel) + { + buffer.copyFrom( + channel, + 0, + fxBypassDryBuffer, + channel, + 0, + buffer.getNumSamples()); + } + } + applyEndpointCorrection(continuity); + rememberOutputEndpoint(continuity); + continuity->skippedLastBlock = true; + return; + } + + const bool dryInputCaptured = + transitioning && writeDryOutput; + if (transitioning && ! dryInputCaptured) + { + // The normal realtime layout is bounded by kMaxFXChannels. If a + // hostile/invalid layout exceeds it, retain memory safety and fall + // back to the requested hard state instead of allocating here. + continuity->hostBypassWetMix = + bypassed ? 0.0f : 1.0f; + if (bypassed) + { + rememberOutputEndpoint(continuity); + continuity->skippedLastBlock = true; + return; + } + } + const bool processed = safeProcessFX( + proc, forceFloat, isInputFXChain, fxIndex); + bool fallbackDryAvailable = writeDryOutput; + if (! processed + && ! fallbackDryAvailable + && canWriteDry + && fxIndex >= 0) + { + prepareLatencyAlignedDry( + bypassDelay, + *proc, + true, + false); + fallbackDryAvailable = true; + } + if (! processed && fallbackDryAvailable) + { + realtimeFallbackReuseCount.fetch_add( + 1, std::memory_order_relaxed); + for (int channel = 0; + channel < buffer.getNumChannels(); + ++channel) + { + buffer.copyFrom( + channel, + 0, + fxBypassDryBuffer, + channel, + 0, + buffer.getNumSamples()); + } + } + finishFXSlot( + continuity, + bypassed, + processed, + dryInputCaptured); }; // ===== PRE-FX AUTOMATION ===== const int numSamps = buffer.getNumSamples(); const double processingSampleRate = juce::jmax(1.0, getSampleRate()); + const auto blockPanLaw = + panLaw.load(std::memory_order_acquire); if (automationGainBuffer.getNumChannels() < 8 || automationGainBuffer.getNumSamples() < numSamps) automationGainBuffer.setSize(8, numSamps, false, false, true); const float staticPreFXVolDb = 0.0f; @@ -1297,7 +3476,12 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc float leftGain = 1.0f; float rightGain = 1.0f; - computePanLawGains(panLaw, pan, juce::Decibels::decibelsToGain(volDb), leftGain, rightGain); + computePanLawGains( + blockPanLaw, + pan, + juce::Decibels::decibelsToGain(volDb), + leftGain, + rightGain); if (bufferChannels >= 1) buffer.setSample(0, i, buffer.getSample(0, i) * leftGain); @@ -1330,11 +3514,10 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc } } - // Channel strip EQ (processed before plugin FX chains) - if (channelStripEQEnabled) - { - channelStripEQ.processBlock(buffer, midiMessages); - } + // Channel strip EQ (processed before plugin FX chains). It stays in the + // callback so its internal dry/wet ramp can make power changes click-free; + // the steady disabled path returns immediately. + channelStripEQ.processBlock(buffer, midiMessages); // Process through input FX chain if (inputFXSnapshot) @@ -1348,8 +3531,13 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc const bool forceFloat = inputFXPrecisionOverrideSnapshot != nullptr && inputFXPrecisionOverrideSnapshot->count(pluginIndex) > 0 && inputFXPrecisionOverrideSnapshot->at(pluginIndex); - if (plugin && !bypassed) - safeProcessFX(plugin.get(), forceFloat, true, pluginIndex); + if (plugin) + processFXWithHostBypass( + plugin.get(), + forceFloat, + true, + pluginIndex, + bypassed); } } @@ -1357,7 +3545,12 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc // instrument output can be post-processed by normal track FX. if (currentTrackType == TrackType::Instrument && instrumentSnapshot) { - safeProcessFX(instrumentSnapshot.get(), instrumentForceFloat, false, -1); + processFXWithHostBypass( + instrumentSnapshot, + instrumentForceFloat, + false, + -1, + false); } else if (currentTrackType == TrackType::Instrument && !hasTrackBuiltInInstrument) { @@ -1369,11 +3562,12 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc { auto* proc = (*trackFXSnapshot)[fxIdx].get(); if (!proc) continue; + bool bypassed = false; if (trackFXBypassSnapshot != nullptr) { auto bypassIt = trackFXBypassSnapshot->find(fxIdx); if (bypassIt != trackFXBypassSnapshot->end() && bypassIt->second) - continue; + bypassed = true; } const bool forceFloat = trackFXPrecisionOverrideSnapshot != nullptr && trackFXPrecisionOverrideSnapshot->count(fxIdx) > 0 @@ -1394,14 +3588,109 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc if (hasSidechain) { + auto* continuity = resolveFXContinuity( + proc, false, fxIdx, bypassed); + if (continuity == nullptr && bypassed) + continue; + const bool transitioning = + continuity != nullptr + && std::abs( + continuity->hostBypassWetMix + - (bypassed ? 0.0f : 1.0f)) + > 1.0e-6f; + const bool canWriteDry = + canUseBypassDryBuffer(); + auto* bypassDelay = + resolveBypassDelayStorage(false, fxIdx); + prepareLatencyAlignedDry( + bypassDelay, + *proc, + canWriteDry + && (transitioning || bypassed), + true); + + if (continuity != nullptr + && bypassed + && continuity->hostBypassWetMix <= 0.0f) + { + if (canWriteDry) + { + for (int channel = 0; + channel < buffer.getNumChannels(); + ++channel) + { + buffer.copyFrom( + channel, + 0, + fxBypassDryBuffer, + channel, + 0, + buffer.getNumSamples()); + } + } + applyEndpointCorrection(continuity); + rememberOutputEndpoint(continuity); + continuity->skippedLastBlock = true; + continue; + } + + const bool dryInputCaptured = + transitioning && canWriteDry; + if (transitioning && ! dryInputCaptured) + { + continuity->hostBypassWetMix = + bypassed ? 0.0f : 1.0f; + if (bypassed) + { + rememberOutputEndpoint(continuity); + continuity->skippedLastBlock = true; + continue; + } + } juce::ScopedTryLock pluginProcessLock(proc->getCallbackLock()); if (!pluginProcessLock.isLocked()) { pluginBusySkipCount.fetch_add(1, std::memory_order_relaxed); + if (canWriteDry + && ! (transitioning || bypassed)) + { + prepareLatencyAlignedDry( + bypassDelay, + *proc, + true, + false); + } + if (canWriteDry) + { + realtimeFallbackReuseCount.fetch_add( + 1, std::memory_order_relaxed); + for (int channel = 0; + channel < buffer.getNumChannels(); + ++channel) + { + buffer.copyFrom( + channel, + 0, + fxBypassDryBuffer, + channel, + 0, + buffer.getNumSamples()); + } + } + finishFXSlot( + continuity, + bypassed, + false, + dryInputCaptured); continue; } - applyPluginAutomationForProcessor(proc, false, fxIdx, blockTimeSeconds); + applyPluginAutomationForProcessor( + proc, + false, + fxIdx, + blockTimeSeconds, + pluginAutomationRoutesForBlock); // Sidechain path: expand buffer to include sidechain channels after // the main stereo channels. The plugin's second input bus receives @@ -1511,19 +3800,29 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc for (int ch = 0; ch < bufferChannels; ++ch) buffer.copyFrom(ch, 0, pluginBuffer, ch, 0, numSamps2); } + + finishFXSlot( + continuity, + bypassed, + true, + dryInputCaptured); } else { // No sidechain — use normal channel-safe processing - safeProcessFX(proc, forceFloat, false, fxIdx); + processFXWithHostBypass( + proc, + forceFloat, + false, + fxIdx, + bypassed); } } const double trackProcessDurationMs = isARATrack ? (juce::Time::getMillisecondCounterHiRes() - trackProcessStartMs) : 0.0; if (kEnableARADebugDiagnostics && trackProcessDurationMs > 10.0 - && araController != nullptr - && araController->isActive()) + && activeARAControllerForBlock != nullptr) { logToDisk("ARA track envelope slow: trackId=" + araDebugTrackId + " callback=" + juce::String(static_cast<juce::int64>(currentARAProcessDebugInfo.callbackCounter)) @@ -1534,7 +3833,8 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc } // ===== DC OFFSET REMOVAL (after FX, before gain) ===== - if (dcOffsetRemoval && bufferChannels >= 1) + if (dcOffsetRemoval.load(std::memory_order_acquire) + && bufferChannels >= 1) { double sr = getSampleRate(); if (sr <= 0) sr = 44100.0; @@ -1653,7 +3953,12 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc float volumeGain = juce::Decibels::decibelsToGain(volDB); float lGain = 1.0f; float rGain = 1.0f; - computePanLawGains(panLaw, pan, volumeGain, lGain, rGain); + computePanLawGains( + blockPanLaw, + pan, + volumeGain, + lGain, + rGain); // Apply per-sample gain if (bufferChannels >= 1) @@ -1724,6 +4029,8 @@ void TrackProcessor::processBlockInternal (juce::AudioBuffer<float>& buffer, juc meterSampleCount = 0; } + finishRealtimeFXTailDrain(peak); + } bool TrackProcessor::hasEditor() const @@ -1791,7 +4098,8 @@ bool TrackProcessor::addInputFX(std::unique_ptr<juce::AudioProcessor> plugin, do // Prepare while preserving bus layout (see preparePluginPreservingLayout). preparePluginPreservingLayout(plugin.get(), sr, bs, - resolvePluginPrecisionMode(processingPrecisionMode, false)); + resolvePluginPrecisionMode(processingPrecisionMode, false), + inputChannelCount.load(std::memory_order_acquire)); juce::Logger::writeToLog("TrackProcessor: Added Input FX plugin (" + plugin->getName() + ") prepared at " + juce::String(sr) + "Hz / " + juce::String(bs) + " samples" + @@ -1829,7 +4137,8 @@ bool TrackProcessor::addTrackFX(std::unique_ptr<juce::AudioProcessor> plugin, do // Prepare while preserving bus layout (see preparePluginPreservingLayout). preparePluginPreservingLayout(plugin.get(), sr, bs, - resolvePluginPrecisionMode(processingPrecisionMode, false)); + resolvePluginPrecisionMode(processingPrecisionMode, false), + inputChannelCount.load(std::memory_order_acquire)); juce::Logger::writeToLog("TrackProcessor: Added Track FX plugin (" + plugin->getName() + ") prepared at " + juce::String(sr) + "Hz / " + juce::String(bs) + " samples" + @@ -1863,6 +4172,7 @@ void TrackProcessor::removeInputFX(int index) } inputFXForceFloatOverrides = std::move(updatedOverrides); inputFXBypassedState = std::move(updatedBypass); + remapPluginAutomationRoutesForRemoval(true, index); publishRealtimeStateSnapshots(); juce::Logger::writeToLog("TrackProcessor: Removed Input FX at index " + juce::String(index)); } @@ -1884,7 +4194,16 @@ void TrackProcessor::removeTrackFX(int index) shutdownARA(); } else if (index < araFXIndex) + { --araFXIndex; + if (araFXIndexForRealtime.load( + std::memory_order_acquire) >= 0) + { + araFXIndexForRealtime.store( + araFXIndex, + std::memory_order_release); + } + } trackFXPlugins.erase(trackFXPlugins.begin() + index); std::map<int, bool> updatedOverrides; @@ -1903,6 +4222,7 @@ void TrackProcessor::removeTrackFX(int index) } trackFXForceFloatOverrides = std::move(updatedOverrides); trackFXBypassedState = std::move(updatedBypass); + remapPluginAutomationRoutesForRemoval(false, index); publishRealtimeStateSnapshots(); juce::Logger::writeToLog("TrackProcessor: Removed Track FX at index " + juce::String(index)); } @@ -1913,6 +4233,23 @@ void TrackProcessor::bypassInputFX(int index, bool bypassed) const juce::ScopedLock processorCallbackGuard(getCallbackLock()); if (index >= 0 && index < (int)inputFXPlugins.size()) { + const bool wasBypassed = + inputFXBypassedState.count(index) > 0 + && inputFXBypassedState.at(index); + if (wasBypassed && ! bypassed) + { + if (auto& processor = + inputFXPlugins[static_cast<size_t>(index)]) + { + // reset() is intentionally performed on the control thread + // under the processor lock. The callback uses a try-lock, so + // an arbitrary hosted plugin can never block the audio thread + // or resume with a frozen delay/detector endpoint. + const juce::ScopedLock pluginGuard( + processor->getCallbackLock()); + processor->reset(); + } + } if (bypassed) inputFXBypassedState[index] = true; else @@ -1927,6 +4264,19 @@ void TrackProcessor::bypassTrackFX(int index, bool bypassed) const juce::ScopedLock processorCallbackGuard(getCallbackLock()); if (index >= 0 && index < (int)trackFXPlugins.size()) { + const bool wasBypassed = + trackFXBypassedState.count(index) > 0 + && trackFXBypassedState.at(index); + if (wasBypassed && ! bypassed) + { + if (auto& processor = + trackFXPlugins[static_cast<size_t>(index)]) + { + const juce::ScopedLock pluginGuard( + processor->getCallbackLock()); + processor->reset(); + } + } if (bypassed) trackFXBypassedState[index] = true; else @@ -1948,8 +4298,9 @@ int TrackProcessor::getNumTrackFX() const int TrackProcessor::getNumSends() const { - auto snapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); - return snapshot != nullptr ? static_cast<int>(snapshot->size()) : 0; + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + return graph != nullptr ? static_cast<int>(graph->sends.size()) : 0; } juce::AudioProcessor* TrackProcessor::getInputFXProcessor(int index) @@ -1996,32 +4347,58 @@ std::shared_ptr<juce::AudioProcessor> TrackProcessor::getTrackFXProcessorShared( std::shared_ptr<const std::vector<std::shared_ptr<juce::AudioProcessor>>> TrackProcessor::getInputFXSnapshot() const { - return std::atomic_load_explicit(&realtimeInputFXSnapshot, std::memory_order_acquire); + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + return graph != nullptr + ? std::shared_ptr<const ProcessorSnapshot>(graph, &graph->inputFX) + : std::shared_ptr<const ProcessorSnapshot>(); } std::shared_ptr<const std::vector<std::shared_ptr<juce::AudioProcessor>>> TrackProcessor::getTrackFXSnapshot() const { - return std::atomic_load_explicit(&realtimeTrackFXSnapshot, std::memory_order_acquire); + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + return graph != nullptr + ? std::shared_ptr<const ProcessorSnapshot>(graph, &graph->trackFX) + : std::shared_ptr<const ProcessorSnapshot>(); } std::shared_ptr<const std::map<int, bool>> TrackProcessor::getInputFXBypassSnapshot() const { - return std::atomic_load_explicit(&realtimeInputFXBypassSnapshot, std::memory_order_acquire); + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + return graph != nullptr + ? std::shared_ptr<const BypassSnapshot>(graph, &graph->inputFXBypass) + : std::shared_ptr<const BypassSnapshot>(); } std::shared_ptr<const std::map<int, bool>> TrackProcessor::getTrackFXBypassSnapshot() const { - return std::atomic_load_explicit(&realtimeTrackFXBypassSnapshot, std::memory_order_acquire); + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + return graph != nullptr + ? std::shared_ptr<const BypassSnapshot>(graph, &graph->trackFXBypass) + : std::shared_ptr<const BypassSnapshot>(); } std::shared_ptr<const std::map<int, bool>> TrackProcessor::getInputFXPrecisionOverrideSnapshot() const { - return std::atomic_load_explicit(&realtimeInputFXPrecisionOverrideSnapshot, std::memory_order_acquire); + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + return graph != nullptr + ? std::shared_ptr<const PrecisionOverrideSnapshot>( + graph, &graph->inputFXPrecisionOverrides) + : std::shared_ptr<const PrecisionOverrideSnapshot>(); } std::shared_ptr<const std::map<int, bool>> TrackProcessor::getTrackFXPrecisionOverrideSnapshot() const { - return std::atomic_load_explicit(&realtimeTrackFXPrecisionOverrideSnapshot, std::memory_order_acquire); + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + return graph != nullptr + ? std::shared_ptr<const PrecisionOverrideSnapshot>( + graph, &graph->trackFXPrecisionOverrides) + : std::shared_ptr<const PrecisionOverrideSnapshot>(); } bool TrackProcessor::reorderInputFX(int fromIndex, int toIndex) @@ -2061,6 +4438,7 @@ bool TrackProcessor::reorderInputFX(int fromIndex, int toIndex) } inputFXForceFloatOverrides = std::move(updatedOverrides); inputFXBypassedState = std::move(updatedBypass); + remapPluginAutomationRoutesForReorder(true, fromIndex, toIndex); publishRealtimeStateSnapshots(); juce::Logger::writeToLog("TrackProcessor: Reordered input FX from " + @@ -2105,12 +4483,20 @@ bool TrackProcessor::reorderTrackFX(int fromIndex, int toIndex) } trackFXForceFloatOverrides = std::move(updatedOverrides); trackFXBypassedState = std::move(updatedBypass); + remapPluginAutomationRoutesForReorder(false, fromIndex, toIndex); if (araFXIndex == fromIndex) araFXIndex = toIndex; else if (fromIndex < toIndex && araFXIndex > fromIndex && araFXIndex <= toIndex) --araFXIndex; else if (fromIndex > toIndex && araFXIndex >= toIndex && araFXIndex < fromIndex) ++araFXIndex; + if (araFXIndexForRealtime.load( + std::memory_order_acquire) >= 0) + { + araFXIndexForRealtime.store( + araFXIndex, + std::memory_order_release); + } publishRealtimeStateSnapshots(); juce::Logger::writeToLog("TrackProcessor: Reordered track FX from " + @@ -2139,11 +4525,12 @@ void TrackProcessor::clearSidechainSource(int pluginIndex) juce::String TrackProcessor::getSidechainSource(int pluginIndex) const { - auto snapshot = std::atomic_load_explicit(&realtimeSidechainSnapshot, std::memory_order_acquire); - if (snapshot != nullptr) + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + if (graph != nullptr) { - auto it = snapshot->find(pluginIndex); - if (it != snapshot->end()) + const auto it = graph->sidechainSources.find(pluginIndex); + if (it != graph->sidechainSources.end()) return it->second; } return {}; @@ -2156,8 +4543,9 @@ void TrackProcessor::setSidechainBuffer(const juce::AudioBuffer<float>* buffer) bool TrackProcessor::hasAnySidechainSources() const { - auto snapshot = std::atomic_load_explicit(&realtimeSidechainSnapshot, std::memory_order_acquire); - return snapshot != nullptr && !snapshot->empty(); + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + return graph != nullptr && !graph->sidechainSources.empty(); } //============================================================================== @@ -2225,41 +4613,66 @@ void TrackProcessor::setSendPreFader(int sendIndex, bool preFader) juce::String TrackProcessor::getSendDestination(int sendIndex) const { - auto snapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); - if (snapshot != nullptr && sendIndex >= 0 && sendIndex < (int)snapshot->size()) - return (*snapshot)[sendIndex].destTrackId; + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + if (graph != nullptr + && sendIndex >= 0 + && sendIndex < static_cast<int>(graph->sends.size())) + { + return graph->sends[static_cast<size_t>(sendIndex)].destTrackId; + } return {}; } float TrackProcessor::getSendLevel(int sendIndex) const { - auto snapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); - if (snapshot != nullptr && sendIndex >= 0 && sendIndex < (int)snapshot->size()) - return (*snapshot)[sendIndex].level; + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + if (graph != nullptr + && sendIndex >= 0 + && sendIndex < static_cast<int>(graph->sends.size())) + { + return graph->sends[static_cast<size_t>(sendIndex)].level; + } return 0.0f; } float TrackProcessor::getSendPan(int sendIndex) const { - auto snapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); - if (snapshot != nullptr && sendIndex >= 0 && sendIndex < (int)snapshot->size()) - return (*snapshot)[sendIndex].pan; + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + if (graph != nullptr + && sendIndex >= 0 + && sendIndex < static_cast<int>(graph->sends.size())) + { + return graph->sends[static_cast<size_t>(sendIndex)].pan; + } return 0.0f; } bool TrackProcessor::getSendEnabled(int sendIndex) const { - auto snapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); - if (snapshot != nullptr && sendIndex >= 0 && sendIndex < (int)snapshot->size()) - return (*snapshot)[sendIndex].enabled; + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + if (graph != nullptr + && sendIndex >= 0 + && sendIndex < static_cast<int>(graph->sends.size())) + { + return graph->sends[static_cast<size_t>(sendIndex)].enabled; + } return false; } bool TrackProcessor::getSendPreFader(int sendIndex) const { - auto snapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); - if (snapshot != nullptr && sendIndex >= 0 && sendIndex < (int)snapshot->size()) - return (*snapshot)[sendIndex].preFader; + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + if (graph != nullptr + && sendIndex >= 0 + && sendIndex < static_cast<int>(graph->sends.size())) + { + return graph->sends[static_cast<size_t>(sendIndex)].preFader; + } return false; } @@ -2267,9 +4680,15 @@ void TrackProcessor::fillSendBuffer(int sendIndex, const juce::AudioBuffer<float const juce::AudioBuffer<float>& postFaderBuf, juce::AudioBuffer<float>& destBuffer, int numSamples) const { - auto snapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); - if (snapshot == nullptr || sendIndex < 0 || sendIndex >= (int)snapshot->size()) return; - const auto& send = (*snapshot)[sendIndex]; + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + if (graph == nullptr + || sendIndex < 0 + || sendIndex >= static_cast<int>(graph->sends.size())) + { + return; + } + const auto& send = graph->sends[static_cast<size_t>(sendIndex)]; if (!send.enabled || send.level <= 0.0f) return; const auto& srcBuf = send.preFader ? preFaderBuf : postFaderBuf; @@ -2334,6 +4753,43 @@ void TrackProcessor::clearInstrument() juce::Logger::writeToLog("TrackProcessor: Instrument plugin removed"); } +bool TrackProcessor::isUsingFallbackInstrument() const +{ + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + return trackType.load(std::memory_order_acquire) == TrackType::Instrument + && (graph == nullptr || graph->instrument == nullptr); +} + +void TrackProcessor::publishFallbackSamplerSample( + std::shared_ptr<const FallbackSamplerSample> sample) +{ + const juce::ScopedLock publicationGuard( + realtimeAuxPublicationLock); + reclaimRetiredRealtimeAuxOwners(); + const auto previous = std::atomic_load_explicit( + &fallbackSamplerSample, + std::memory_order_acquire); + { + const juce::ScopedLock retirementGuard( + realtimeAuxRetirementLock); + if (previous != nullptr + && previous.get() != sample.get()) + { + retiredRealtimeAuxOwners.push_back( + std::static_pointer_cast<const void>( + previous)); + } + std::atomic_store_explicit( + &fallbackSamplerSample, + sample, + std::memory_order_release); + fallbackSamplerSampleForAudio.store( + sample.get(), + std::memory_order_seq_cst); + } +} + bool TrackProcessor::loadFallbackSamplerSample(const juce::String& filePath, int rootNote) { const juce::File sampleFile(filePath); @@ -2482,9 +4938,9 @@ bool TrackProcessor::loadFallbackSamplerSample(const juce::String& filePath, int } const juce::ScopedLock processorCallbackGuard(getCallbackLock()); - std::atomic_store_explicit(&fallbackSamplerSample, - std::static_pointer_cast<const FallbackSamplerSample>(sample), - std::memory_order_release); + publishFallbackSamplerSample( + std::static_pointer_cast<const FallbackSamplerSample>( + sample)); clearFallbackInstrumentState(); fallbackInstrumentResetRequested.store(true, std::memory_order_release); juce::Logger::writeToLog("TrackProcessor: Loaded fallback SoundFont sample " @@ -2515,9 +4971,9 @@ bool TrackProcessor::loadFallbackSamplerSample(const juce::String& filePath, int return false; const juce::ScopedLock processorCallbackGuard(getCallbackLock()); - std::atomic_store_explicit(&fallbackSamplerSample, - std::static_pointer_cast<const FallbackSamplerSample>(sample), - std::memory_order_release); + publishFallbackSamplerSample( + std::static_pointer_cast<const FallbackSamplerSample>( + sample)); clearFallbackInstrumentState(); fallbackInstrumentResetRequested.store(true, std::memory_order_release); juce::Logger::writeToLog("TrackProcessor: Loaded fallback sampler sample " + sampleFile.getFullPathName()); @@ -2527,9 +4983,7 @@ bool TrackProcessor::loadFallbackSamplerSample(const juce::String& filePath, int void TrackProcessor::clearFallbackSamplerSample() { const juce::ScopedLock processorCallbackGuard(getCallbackLock()); - std::atomic_store_explicit(&fallbackSamplerSample, - std::shared_ptr<const FallbackSamplerSample>(), - std::memory_order_release); + publishFallbackSamplerSample(nullptr); clearFallbackInstrumentState(); fallbackInstrumentResetRequested.store(true, std::memory_order_release); } @@ -2686,7 +5140,9 @@ void TrackProcessor::handleFallbackInstrumentMidi(const juce::MidiMessage& messa if (message.isNoteOn()) { - auto samplerSample = std::atomic_load_explicit(&fallbackSamplerSample, std::memory_order_acquire); + const auto* const samplerSample = + fallbackSamplerSampleForAudio.load( + std::memory_order_seq_cst); const int note = juce::jlimit(0, 127, message.getNoteNumber()); fallbackInstrumentNoteActive[static_cast<size_t>(channelIndex)][static_cast<size_t>(note)] = true; fallbackInstrumentNoteReleasing[static_cast<size_t>(channelIndex)][static_cast<size_t>(note)] = false; @@ -2764,7 +5220,9 @@ void TrackProcessor::renderFallbackInstrument(juce::AudioBuffer<float>& buffer, clearFallbackInstrumentState(); const int bufferChannels = buffer.getNumChannels(); - auto samplerSample = std::atomic_load_explicit(&fallbackSamplerSample, std::memory_order_acquire); + const auto* const samplerSample = + fallbackSamplerSampleForAudio.load( + std::memory_order_seq_cst); const int instrumentMode = juce::jlimit(0, 2, static_cast<int>(std::round(fallbackInstrumentMode.load(std::memory_order_relaxed)))); const bool useSampler = instrumentMode != 2 && samplerSample != nullptr @@ -3028,7 +5486,8 @@ bool TrackProcessor::enqueueMidiMessage(const juce::MidiMessage& message, int sa void TrackProcessor::setScheduledMIDIClips(std::vector<ScheduledMIDIClip> clips) { auto sharedClips = std::make_shared<const std::vector<ScheduledMIDIClip>>(std::move(clips)); - std::atomic_store_explicit(&scheduledMIDIClips, sharedClips, std::memory_order_release); + publishScheduledMIDIClips( + std::move(sharedClips)); requestMIDIChase(); } @@ -3103,11 +5562,11 @@ std::vector<TrackProcessor::MIDINoteActivity> TrackProcessor::getRecentMIDINoteA } void TrackProcessor::appendScheduledMIDIToBuffer(juce::MidiBuffer& destination, + const std::vector<ScheduledMIDIClip>* const clips, double blockTimeSeconds, int numSamples, double sampleRate) const { - auto clips = std::atomic_load_explicit(&scheduledMIDIClips, std::memory_order_acquire); - if (!clips || clips->empty() || sampleRate <= 0.0) + if (clips == nullptr || clips->empty() || sampleRate <= 0.0) return; const double blockEndTimeSeconds = blockTimeSeconds + (static_cast<double>(numSamples) / sampleRate); @@ -3135,11 +5594,11 @@ void TrackProcessor::appendScheduledMIDIToBuffer(juce::MidiBuffer& destination, } void TrackProcessor::appendScheduledMIDIChaseToBuffer(juce::MidiBuffer& destination, + const std::vector<ScheduledMIDIClip>* const clips, double blockTimeSeconds, double sampleRate) const { - auto clips = std::atomic_load_explicit(&scheduledMIDIClips, std::memory_order_acquire); - if (!clips || clips->empty() || sampleRate <= 0.0) + if (clips == nullptr || clips->empty() || sampleRate <= 0.0) return; std::array<std::array<const ScheduledMIDIEvent*, 128>, 16> activeNoteStarts {}; @@ -3255,7 +5714,8 @@ void TrackProcessor::appendQueuedMIDIToBuffer(juce::MidiBuffer& destination, int } void TrackProcessor::applyMIDIAutomationToBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, - int numSamples, double sampleRate) + int numSamples, double sampleRate, + const MIDICCAutomationRouteSnapshot* ccRoutes) { if (numSamples <= 0 || sampleRate <= 0.0) return; @@ -3266,8 +5726,9 @@ void TrackProcessor::applyMIDIAutomationToBuffer(juce::MidiBuffer& destination, && midiPitchBendAutomation.getNumPoints() > 0; const bool channelPressureActive = shouldApplyAutomation(midiChannelPressureAutomation) && midiChannelPressureAutomation.getNumPoints() > 0; - auto ccSnapshot = std::atomic_load_explicit(&midiCCAutomationSnapshot, std::memory_order_acquire); - const bool hasCCRoutedAutomation = ccSnapshot != nullptr && !ccSnapshot->empty(); + const bool hasCCRoutedAutomation = + ccRoutes != nullptr + && ! ccRoutes->empty(); if (!velocityActive && !pitchBendActive && !channelPressureActive && !hasCCRoutedAutomation) return; @@ -3296,7 +5757,11 @@ void TrackProcessor::applyMIDIAutomationToBuffer(juce::MidiBuffer& destination, auto addForConfiguredChannels = [this, &transformed] (auto createMessage) { - const int configuredChannel = juce::jlimit(0, 16, midiChannel); + const int configuredChannel = juce::jlimit( + 0, + 16, + midiChannel.load( + std::memory_order_acquire)); if (configuredChannel > 0) { transformed.addEvent(createMessage(configuredChannel), 0); @@ -3320,9 +5785,9 @@ void TrackProcessor::applyMIDIAutomationToBuffer(juce::MidiBuffer& destination, addForConfiguredChannels([pressure] (int channel) { return juce::MidiMessage::channelPressureChange(channel, pressure); }); } - if (ccSnapshot) + if (hasCCRoutedAutomation) { - for (const auto& route : *ccSnapshot) + for (const auto& route : *ccRoutes) { if (!route || !route->automation || route->controller < 0 || route->controller > 127) continue; @@ -3343,7 +5808,12 @@ void TrackProcessor::applyMIDIAutomationToBuffer(juce::MidiBuffer& destination, bool TrackProcessor::hasQueuedMIDI() const { - return midiQueueReadIndex.load(std::memory_order_acquire) != midiQueueWriteIndex.load(std::memory_order_acquire); + return allNotesOffRequested.load( + std::memory_order_acquire) + || midiQueueReadIndex.load( + std::memory_order_acquire) + != midiQueueWriteIndex.load( + std::memory_order_acquire); } bool TrackProcessor::hasScheduledMIDIClips() const @@ -3376,10 +5846,13 @@ int TrackProcessor::getScheduledMIDIEventCount() const return count; } -bool TrackProcessor::hasScheduledMIDIInBlock(double blockTimeSeconds, int numSamples, double sampleRate) const +bool TrackProcessor::hasScheduledMIDIInBlock( + double blockTimeSeconds, + int numSamples, + double sampleRate, + const std::vector<ScheduledMIDIClip>* const clips) const { - auto clips = std::atomic_load_explicit(&scheduledMIDIClips, std::memory_order_acquire); - if (!clips || clips->empty() || sampleRate <= 0.0) + if (clips == nullptr || clips->empty() || sampleRate <= 0.0) return false; const double blockEndTimeSeconds = blockTimeSeconds + (static_cast<double>(numSamples) / sampleRate); @@ -3409,15 +5882,100 @@ void TrackProcessor::buildMidiBuffer(juce::MidiBuffer& destination, double block destination.clear(); appendQueuedMIDIToBuffer(destination, numSamples); - - if (playing) + if (allNotesOffRequested.exchange( + false, std::memory_order_acq_rel)) { - if (scheduledMIDIChaseRequested.exchange(false, std::memory_order_acq_rel)) - appendScheduledMIDIChaseToBuffer(destination, blockTimeSeconds, sampleRate); - appendScheduledMIDIToBuffer(destination, blockTimeSeconds, numSamples, sampleRate); + for (size_t channel = 0; + channel < activeMIDINotes.size(); + ++channel) + { + for (size_t note = 0; + note < activeMIDINotes[channel].size(); + ++note) + { + if (! activeMIDINotes[channel][note]) + continue; + + destination.addEvent( + juce::MidiMessage::noteOff( + static_cast<int>(channel) + 1, + static_cast<int>(note)), + 0); + activeMIDINotes[channel][note] = + false; + } + + const int midiChannelNumber = + static_cast<int>(channel) + 1; + destination.addEvent( + juce::MidiMessage::allNotesOff( + midiChannelNumber), + 0); + destination.addEvent( + juce::MidiMessage::controllerEvent( + midiChannelNumber, 64, 0), + 0); + destination.addEvent( + juce::MidiMessage::controllerEvent( + midiChannelNumber, 120, 0), + 0); + destination.addEvent( + juce::MidiMessage::controllerEvent( + midiChannelNumber, 121, 0), + 0); + destination.addEvent( + juce::MidiMessage::controllerEvent( + midiChannelNumber, 123, 0), + 0); + destination.addEvent( + juce::MidiMessage::pitchWheel( + midiChannelNumber, 8192), + 0); + } } - applyMIDIAutomationToBuffer(destination, blockTimeSeconds, numSamples, sampleRate); + if (playing + && hasScheduledMIDIClipsForAudio.load( + std::memory_order_acquire)) + { + const ScopedTrackRealtimeReader scheduledMIDIReadGuard( + scheduledMIDIAudioReaders); + const auto* const scheduledClips = + scheduledMIDIClipsForAudio.load( + std::memory_order_seq_cst); + if (scheduledMIDIChaseRequested.exchange(false, std::memory_order_acq_rel)) + { + appendScheduledMIDIChaseToBuffer( + destination, + scheduledClips, + blockTimeSeconds, + sampleRate); + } + appendScheduledMIDIToBuffer( + destination, + scheduledClips, + blockTimeSeconds, + numSamples, + sampleRate); + } + + const bool hasMIDIAutomationRoutesForBlock = + hasPublishedMIDICCAutomationRoutes.load( + std::memory_order_acquire); + const ScopedTrackRealtimeReader midiAutomationReadGuard( + realtimeAuxAudioReaders, + hasMIDIAutomationRoutesForBlock); + const auto* const midiAutomationRoutesForBlock = + hasMIDIAutomationRoutesForBlock + ? midiCCAutomationSnapshotForAudio.load( + std::memory_order_seq_cst) + : nullptr; + applyMIDIAutomationToBuffer( + destination, + blockTimeSeconds, + numSamples, + sampleRate, + midiAutomationRoutesForBlock); lastBuiltMidiEventCount.store(destination.getNumEvents(), std::memory_order_relaxed); int prevMax = maxBuiltMidiEventCount.load(std::memory_order_relaxed); @@ -3434,14 +5992,33 @@ void TrackProcessor::buildMidiBuffer(juce::MidiBuffer& destination, double block bool TrackProcessor::needsProcessing(double blockTimeSeconds, int numSamples, double sampleRate, bool playing) const { + if (realtimeFXTailActive.load(std::memory_order_acquire) + || realtimeFXTailResetPending.load(std::memory_order_acquire)) + { + return true; + } + + const ScopedTrackRealtimeReader graphReadGuard( + realtimeGraphAudioReaders); + const auto* const graph = + realtimeGraphSnapshotForAudio.load( + std::memory_order_seq_cst); + const auto* const trackFXSnapshot = + graph != nullptr ? &graph->trackFX : nullptr; + const auto* const inputFXSnapshot = + graph != nullptr ? &graph->inputFX : nullptr; + + if (hasInternalAuditionSourceActive(trackFXSnapshot) + || hasInternalAuditionSourceActive(inputFXSnapshot)) + return true; + // Instrument tracks must always be processed so they can respond to // live MIDI input and produce sustain / reverb tails after note-off. if (trackType.load(std::memory_order_acquire) == TrackType::Instrument) { - if (std::atomic_load_explicit(&realtimeInstrumentSnapshot, std::memory_order_acquire) != nullptr) + if (graph != nullptr && graph->instrument != nullptr) return true; - auto trackFXSnapshot = std::atomic_load_explicit(&realtimeTrackFXSnapshot, std::memory_order_acquire); if (trackFXSnapshot) { for (const auto& plugin : *trackFXSnapshot) @@ -3459,11 +6036,31 @@ bool TrackProcessor::needsProcessing(double blockTimeSeconds, int numSamples, if (hasQueuedMIDI()) return true; - if (playing && scheduledMIDIChaseRequested.load(std::memory_order_acquire) && hasScheduledMIDIClips()) - return true; + if (playing + && hasScheduledMIDIClipsForAudio.load( + std::memory_order_acquire)) + { + const ScopedTrackRealtimeReader scheduledMIDIReadGuard( + scheduledMIDIAudioReaders); + const auto* const scheduledClips = + scheduledMIDIClipsForAudio.load( + std::memory_order_seq_cst); + if (scheduledClips != nullptr + && scheduledMIDIChaseRequested.load( + std::memory_order_acquire)) + { + return true; + } - if (playing && hasScheduledMIDIInBlock(blockTimeSeconds, numSamples, sampleRate)) - return true; + if (hasScheduledMIDIInBlock( + blockTimeSeconds, + numSamples, + sampleRate, + scheduledClips)) + { + return true; + } + } if (playing && hasMIDIAutomation()) return true; @@ -3476,36 +6073,20 @@ void TrackProcessor::queueAllNotesOff(bool requestChase) if (requestChase) requestMIDIChase(); fallbackInstrumentResetRequested.store(true, std::memory_order_release); - for (size_t channel = 0; channel < activeMIDINotes.size(); ++channel) - { - for (size_t note = 0; note < activeMIDINotes[channel].size(); ++note) - { - if (!activeMIDINotes[channel][note]) - continue; - - enqueueMidiMessage(juce::MidiMessage::noteOff(static_cast<int>(channel) + 1, - static_cast<int>(note))); - activeMIDINotes[channel][note] = false; - } - - enqueueMidiMessage(juce::MidiMessage::allNotesOff(static_cast<int>(channel) + 1)); - enqueueMidiMessage(juce::MidiMessage::controllerEvent(static_cast<int>(channel) + 1, 64, 0)); - enqueueMidiMessage(juce::MidiMessage::controllerEvent(static_cast<int>(channel) + 1, 120, 0)); - enqueueMidiMessage(juce::MidiMessage::controllerEvent(static_cast<int>(channel) + 1, 121, 0)); - enqueueMidiMessage(juce::MidiMessage::controllerEvent(static_cast<int>(channel) + 1, 123, 0)); - enqueueMidiMessage(juce::MidiMessage::pitchWheel(static_cast<int>(channel) + 1, 8192)); - } + allNotesOffRequested.store( + true, std::memory_order_release); } std::vector<juce::String> TrackProcessor::getSidechainSourceSnapshot() const { - auto snapshot = std::atomic_load_explicit(&realtimeSidechainSnapshot, std::memory_order_acquire); + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); std::vector<juce::String> sourceIds; - if (snapshot == nullptr) + if (graph == nullptr) return sourceIds; - sourceIds.reserve(snapshot->size()); - for (const auto& entry : *snapshot) + sourceIds.reserve(graph->sidechainSources.size()); + for (const auto& entry : graph->sidechainSources) { if (entry.second.isNotEmpty()) sourceIds.push_back(entry.second); @@ -3515,18 +6096,26 @@ std::vector<juce::String> TrackProcessor::getSidechainSourceSnapshot() const std::vector<TrackProcessor::RealtimeSendInfo> TrackProcessor::getRealtimeSendSnapshot() const { - auto snapshotData = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); std::vector<RealtimeSendInfo> snapshot; - if (snapshotData == nullptr) + if (graph == nullptr) return snapshot; - snapshot.reserve(snapshotData->size()); - for (const auto& send : *snapshotData) + snapshot.reserve(graph->sends.size()); + for (const auto& send : graph->sends) { RealtimeSendInfo info; info.destTrackId = send.destTrackId; info.level = send.level; info.pan = send.pan; + const float phaseMultiplier = send.phaseInvert ? -1.0f : 1.0f; + const float panAngle = + (send.pan + 1.0f) * juce::MathConstants<float>::pi / 4.0f; + info.leftGain = + std::cos(panAngle) * send.level * phaseMultiplier; + info.rightGain = + std::sin(panAngle) * send.level * phaseMultiplier; info.enabled = send.enabled; info.preFader = send.preFader; info.phaseInvert = send.phaseInvert; @@ -3547,7 +6136,8 @@ void TrackProcessor::setInputFXPrecisionOverride(int index, bool forceFloat) double sr = getSampleRate() > 0 ? getSampleRate() : 44100.0; int bs = getSafeHostedPluginBlockSize(getBlockSize()); preparePluginPreservingLayout(plugin, sr, bs, - resolvePluginPrecisionMode(processingPrecisionMode, forceFloat)); + resolvePluginPrecisionMode(processingPrecisionMode, forceFloat), + inputChannelCount.load(std::memory_order_acquire)); } publishRealtimeStateSnapshots(); } @@ -3564,7 +6154,8 @@ void TrackProcessor::setTrackFXPrecisionOverride(int index, bool forceFloat) double sr = getSampleRate() > 0 ? getSampleRate() : 44100.0; int bs = getSafeHostedPluginBlockSize(getBlockSize()); preparePluginPreservingLayout(plugin, sr, bs, - resolvePluginPrecisionMode(processingPrecisionMode, forceFloat)); + resolvePluginPrecisionMode(processingPrecisionMode, forceFloat), + inputChannelCount.load(std::memory_order_acquire)); } publishRealtimeStateSnapshots(); } @@ -3621,13 +6212,15 @@ void TrackProcessor::setProcessingPrecisionMode(ProcessingPrecisionMode mode) if (auto* plugin = inputFXPlugins[static_cast<size_t>(index)].get()) preparePluginPreservingLayout(plugin, sr, bs, resolvePluginPrecisionMode(processingPrecisionMode, - getInputFXPrecisionOverride(index))); + getInputFXPrecisionOverride(index)), + inputChannelCount.load(std::memory_order_acquire)); for (int index = 0; index < static_cast<int>(trackFXPlugins.size()); ++index) if (auto* plugin = trackFXPlugins[static_cast<size_t>(index)].get()) preparePluginPreservingLayout(plugin, sr, bs, resolvePluginPrecisionMode(processingPrecisionMode, - getTrackFXPrecisionOverride(index))); + getTrackFXPrecisionOverride(index)), + inputChannelCount.load(std::memory_order_acquire)); if (instrumentPlugin) preparePluginPreservingLayout(instrumentPlugin.get(), sr, bs, @@ -3640,49 +6233,162 @@ void TrackProcessor::setProcessingPrecisionMode(ProcessingPrecisionMode mode) int TrackProcessor::getChainLatency() const { - int totalLatency = 0; + juce::int64 totalLatency = 0; + const auto addProcessorLatency = [&totalLatency] (const juce::AudioProcessor* processor) + { + if (processor != nullptr) + totalLatency += juce::jmax(0, processor->getLatencySamples()); + }; + for (int index = 0; index < static_cast<int>(inputFXPlugins.size()); ++index) { const auto& plugin = inputFXPlugins[static_cast<size_t>(index)]; - if (plugin && !getInputFXBypassed(index)) - totalLatency += plugin->getLatencySamples(); + if (plugin) + addProcessorLatency(plugin.get()); } + addProcessorLatency(instrumentPlugin.get()); for (int index = 0; index < static_cast<int>(trackFXPlugins.size()); ++index) { const auto& plugin = trackFXPlugins[static_cast<size_t>(index)]; - if (plugin && !getTrackFXBypassed(index)) - totalLatency += plugin->getLatencySamples(); + if (plugin) + addProcessorLatency(plugin.get()); } - return totalLatency; + return static_cast<int>(std::min<juce::int64>(totalLatency, + std::numeric_limits<int>::max())); } void TrackProcessor::setPDCDelay(int delaySamples) { - pdcDelaySamples.store(delaySamples, std::memory_order_relaxed); - pdcDelayDirty.store(true, std::memory_order_release); + const int safeDelaySamples = + juce::jmax(0, delaySamples); + if (pdcDelaySamples.exchange( + safeDelaySamples, + std::memory_order_acq_rel) + != safeDelaySamples) + { + pdcDelayDirty.store( + true, std::memory_order_release); + } +} + +void TrackProcessor::resetPDCDelayState() +{ + pdcDelayLine.reset(); + pdcCurrentDelaySamples = + juce::jlimit( + 0, + pdcDelayLine + .getMaximumDelayInSamples(), + pdcDelaySamples.load( + std::memory_order_relaxed)); + pdcTargetDelaySamples = + pdcCurrentDelaySamples; + pdcPendingDelaySamples = + pdcCurrentDelaySamples; + pdcTransitionSamplesRemaining = 0; + pdcDelayLine.setDelay( + static_cast<float>( + pdcCurrentDelaySamples)); + pdcDelayDirty.store(false, std::memory_order_release); +} + +void TrackProcessor::resetOfflineRenderState() +{ + resetPDCDelayState(); + channelStripEQ.reset(); + dcFilterStateL = 0.0f; + dcFilterStateR = 0.0f; + dcPrevInputL = 0.0f; + dcPrevInputR = 0.0f; + clearFallbackInstrumentState(); + for (auto& channelNotes : activeMIDINotes) + channelNotes.fill(false); + fallbackInstrumentResetRequested.store(false, std::memory_order_release); + preFaderBuffer.clear(); + automationGainBuffer.clear(); + realtimeFallbackBuffer.clear(); + + // State/preset restoration can change a parameter without changing its + // automation lane value. Force the first block of every offline pass to + // re-apply that value instead of trusting a cache from realtime/pass 1. + invalidatePluginAutomationCache(); +} + +void TrackProcessor::invalidatePluginAutomationCache() noexcept +{ + if (!hasPublishedPluginAutomationRoutes.load(std::memory_order_acquire)) + return; + + auto snapshot = std::atomic_load_explicit(&pluginAutomationSnapshot, std::memory_order_acquire); + if (snapshot) + for (const auto& route : *snapshot) + if (route) + route->lastAppliedValue.store(std::numeric_limits<float>::quiet_NaN(), + std::memory_order_relaxed); } void TrackProcessor::setChannelStripEQParam(int paramIndex, float value) { - const auto& params = channelStripEQ.getParameters(); - if (paramIndex >= 0 && paramIndex < params.size()) + if (paramIndex < 0 + || paramIndex >= channelStripEQBandCount * channelStripEQValuesPerBand) + return; + + const int surfaceBand = paramIndex / channelStripEQValuesPerBand; + const int field = paramIndex % channelStripEQValuesPerBand; + // The compact strip exposes HPF, four bells, and LPF. Map those onto the + // first six S13EQ bands while setting the two edge filter types explicitly. + auto& band = channelStripEQ.bands[static_cast<size_t>(surfaceBand)]; + if (surfaceBand == 0) + band.type.store(static_cast<float>(S13EQ::FilterType::LowCut), + std::memory_order_relaxed); + else if (surfaceBand == channelStripEQBandCount - 1) + band.type.store(static_cast<float>(S13EQ::FilterType::HighCut), + std::memory_order_relaxed); + else + band.type.store(static_cast<float>(S13EQ::FilterType::Bell), + std::memory_order_relaxed); + + switch (field) { - auto* p = dynamic_cast<juce::RangedAudioParameter*>(params[paramIndex]); - if (p != nullptr) - p->setValueNotifyingHost(p->convertTo0to1(value)); + case 0: + band.freq.store(juce::jlimit(20.0f, 20000.0f, value), + std::memory_order_relaxed); + break; + case 1: + band.gain.store(juce::jlimit(-18.0f, 18.0f, value), + std::memory_order_relaxed); + break; + case 2: + band.q.store(juce::jlimit(0.1f, 10.0f, value), + std::memory_order_relaxed); + break; + case 3: + band.enabled.store(value >= 0.5f ? 1.0f : 0.0f, + std::memory_order_relaxed); + break; + default: + break; } } float TrackProcessor::getChannelStripEQParam(int paramIndex) const { - const auto& params = channelStripEQ.getParameters(); - if (paramIndex >= 0 && paramIndex < params.size()) + if (paramIndex < 0 + || paramIndex >= channelStripEQBandCount * channelStripEQValuesPerBand) + return 0.0f; + + const int surfaceBand = paramIndex / channelStripEQValuesPerBand; + const int field = paramIndex % channelStripEQValuesPerBand; + const auto& band = + channelStripEQ.bands[static_cast<size_t>(surfaceBand)]; + switch (field) { - auto* p = dynamic_cast<juce::RangedAudioParameter*>(params[paramIndex]); - if (p != nullptr) - return p->convertFrom0to1(p->getValue()); + case 0: return band.freq.load(std::memory_order_relaxed); + case 1: return band.gain.load(std::memory_order_relaxed); + case 2: return band.q.load(std::memory_order_relaxed); + case 3: return band.enabled.load(std::memory_order_relaxed); + default: return 0.0f; } - return 0.0f; } //============================================================================== @@ -3699,9 +6405,14 @@ void TrackProcessor::setSendPhaseInvert(int sendIndex, bool invert) bool TrackProcessor::getSendPhaseInvert(int sendIndex) const { - auto snapshot = std::atomic_load_explicit(&realtimeSendSnapshot, std::memory_order_acquire); - if (snapshot != nullptr && sendIndex >= 0 && sendIndex < (int)snapshot->size()) - return (*snapshot)[sendIndex].phaseInvert; + const auto graph = std::atomic_load_explicit( + &realtimeGraphSnapshot, std::memory_order_acquire); + if (graph != nullptr + && sendIndex >= 0 + && sendIndex < static_cast<int>(graph->sends.size())) + { + return graph->sends[static_cast<size_t>(sendIndex)].phaseInvert; + } return false; } @@ -3719,60 +6430,45 @@ void TrackProcessor::setOutputChannels(int startChannel, int numChannels) void TrackProcessor::setMIDIOutputDevice(const juce::String& deviceName) { - const juce::ScopedLock processorCallbackGuard(getCallbackLock()); - if (deviceName == midiOutputDeviceName) + if (midiOutputDispatcher == nullptr + || deviceName == midiOutputDispatcher->getDeviceName()) return; - midiOutputDeviceName = deviceName; - midiOutputDevice.reset(); - - if (deviceName.isNotEmpty()) + if (deviceName.isEmpty()) { - for (const auto& d : juce::MidiOutput::getAvailableDevices()) - { - if (d.name == deviceName) - { - midiOutputDevice = juce::MidiOutput::openDevice(d.identifier); - if (midiOutputDevice) - juce::Logger::writeToLog("TrackProcessor: MIDI output connected: " + deviceName); - break; - } - } + midiOutputDispatcher->disconnect(); + return; } + + if (midiOutputDispatcher->connect(deviceName)) + juce::Logger::writeToLog( + "TrackProcessor: MIDI output connected: " + + deviceName); +} + +juce::String TrackProcessor::getMIDIOutputDeviceName() const +{ + return midiOutputDispatcher != nullptr + ? midiOutputDispatcher->getDeviceName() + : juce::String(); +} + +bool TrackProcessor::hasMIDIOutputDevice() const noexcept +{ + return midiOutputDispatcher != nullptr + && midiOutputDispatcher->isConnected(); } void TrackProcessor::sendMIDIToOutput(const juce::MidiBuffer& buffer, double sampleRate, bool resetMessagesOnly) { - if (midiOutputDevice == nullptr || buffer.isEmpty()) + if (midiOutputDispatcher == nullptr || buffer.isEmpty()) return; if (sampleRate <= 0.0) sampleRate = getSampleRate() > 0.0 ? getSampleRate() : 44100.0; - if (resetMessagesOnly) - { - midiOutputResetBuffer.clear(); - for (const auto metadata : buffer) - { - const auto message = metadata.getMessage(); - const bool isReset = message.isAllNotesOff() - || message.isAllSoundOff() - || (message.isController() - && (message.getControllerNumber() == 64 - || message.getControllerNumber() == 120 - || message.getControllerNumber() == 121 - || message.getControllerNumber() == 123)) - || (message.isPitchWheel() && message.getPitchWheelValue() == 8192); - if (isReset) - midiOutputResetBuffer.addEvent(message, metadata.samplePosition); - } - - if (!midiOutputResetBuffer.isEmpty()) - midiOutputDevice->sendBlockOfMessages(midiOutputResetBuffer, juce::Time::getMillisecondCounterHiRes(), sampleRate); - return; - } - - midiOutputDevice->sendBlockOfMessages(buffer, juce::Time::getMillisecondCounterHiRes(), sampleRate); + midiOutputDispatcher->enqueueBuffer( + buffer, sampleRate, resetMessagesOnly); } // ============================================================================= @@ -3806,6 +6502,9 @@ bool TrackProcessor::initializeARA(int fxIndex, double sampleRate, int araBlockS { if (araFXIndex == fxIndex) { + araFXIndexForRealtime.store( + araFXIndex, + std::memory_order_release); updateARAAttemptStatus(fxIndex, true, true, true, {}); if (onComplete) onComplete(true, true, {}); return true; @@ -3837,12 +6536,18 @@ bool TrackProcessor::initializeARA(int fxIndex, double sampleRate, int araBlockS [this, fxIndex, onComplete] (bool success, bool pluginSupportsARA, const juce::String& errorMessage) { if (success) { + araFXIndexForRealtime.store( + araFXIndex, + std::memory_order_release); juce::Logger::writeToLog("TrackProcessor::initializeARA: ARA initialized at FX index " + juce::String(fxIndex)); updateARAAttemptStatus(fxIndex, true, true, true, {}); } else { + araFXIndexForRealtime.store( + -1, + std::memory_order_release); juce::Logger::writeToLog("TrackProcessor::initializeARA: ARA initialization failed for FX index " + juce::String(fxIndex)); updateARAAttemptStatus(fxIndex, true, pluginSupportsARA, false, errorMessage); @@ -3947,6 +6652,8 @@ juce::String TrackProcessor::getARALastAttemptError() const void TrackProcessor::shutdownARA() { + araFXIndexForRealtime.store( + -1, std::memory_order_release); #if S13_HAS_ARA if (araController) { diff --git a/Source/TrackProcessor.h b/Source/TrackProcessor.h index fa46c32..2b99336 100644 --- a/Source/TrackProcessor.h +++ b/Source/TrackProcessor.h @@ -2,6 +2,7 @@ #include <JuceHeader.h> #include "AutomationList.h" +#include "BuiltInParameterSupport.h" #include "BuiltInEffects.h" #include "ARAHostController.h" #include <map> @@ -32,7 +33,10 @@ enum class ProcessingPrecisionMode Hybrid64 }; -class TrackProcessor : public juce::AudioProcessor +class TrackMIDIOutputDispatcher; + +class TrackProcessor : public juce::AudioProcessor, + private juce::Timer { public: struct ARAProcessDebugInfo @@ -61,6 +65,8 @@ class TrackProcessor : public juce::AudioProcessor juce::String destTrackId; float level = 0.0f; float pan = 0.0f; + float leftGain = 0.0f; + float rightGain = 0.0f; bool enabled = false; bool preFader = false; bool phaseInvert = false; @@ -90,6 +96,7 @@ class TrackProcessor : public juce::AudioProcessor bool isInputFX = false; int fxIndex = -1; int paramIndex = -1; + juce::String builtInParamId; int midiCC = -1; }; @@ -111,6 +118,7 @@ class TrackProcessor : public juce::AudioProcessor bool producesMidi() const override; bool isMidiEffect() const override; double getTailLengthSeconds() const override; + double getOfflineRenderTailLengthSeconds() const; int getNumPrograms() override; int getCurrentProgram() override; @@ -126,13 +134,30 @@ class TrackProcessor : public juce::AudioProcessor void resetRMS() { currentRMS.store (0.0f, std::memory_order_relaxed); meterPeakAccum = 0.0f; meterSampleCount = 0; } bool isClipLatched() const { return clipLatched.load(std::memory_order_relaxed); } void resetClipLatch() { clipLatched.store(false, std::memory_order_relaxed); } + void registerMIDIInputActivity(const juce::MidiMessage& message) noexcept; + float getMIDIInputActivityLevel() const noexcept; // Recording & Monitoring (Phase 1) - void setRecordArmed(bool armed) { if (!isRecordSafe) isRecordArmed = armed; } - bool getRecordArmed() const { return isRecordArmed; } + void setRecordArmed(bool armed) + { + if (! isRecordSafe.load(std::memory_order_acquire)) + isRecordArmed.store(armed, std::memory_order_release); + } + bool getRecordArmed() const + { + return isRecordArmed.load(std::memory_order_acquire); + } - void setRecordSafe(bool safe) { isRecordSafe = safe; if (safe) isRecordArmed = false; } - bool getRecordSafe() const { return isRecordSafe; } + void setRecordSafe(bool safe) + { + isRecordSafe.store(safe, std::memory_order_release); + if (safe) + isRecordArmed.store(false, std::memory_order_release); + } + bool getRecordSafe() const + { + return isRecordSafe.load(std::memory_order_acquire); + } void setInputMonitoring(bool enabled) { isInputMonitoringEnabled.store(enabled, std::memory_order_release); } bool getInputMonitoring() const { return isInputMonitoringEnabled.load(std::memory_order_acquire); } @@ -167,6 +192,10 @@ class TrackProcessor : public juce::AudioProcessor std::shared_ptr<const std::map<int, bool>> getTrackFXBypassSnapshot() const; std::shared_ptr<const std::map<int, bool>> getInputFXPrecisionOverrideSnapshot() const; std::shared_ptr<const std::map<int, bool>> getTrackFXPrecisionOverrideSnapshot() const; + // Called from the control thread when hosted latency may have changed. + // Publishes resized immutable delay storage without touching callback-owned + // ring positions. + void refreshHostBypassDelayStorage(); // Sidechain Routing (Phase 4.4) void setSidechainSource(int pluginIndex, const juce::String& sourceTrackId); @@ -207,8 +236,15 @@ class TrackProcessor : public juce::AudioProcessor float getPan() const { return trackPan.load(std::memory_order_relaxed); } // Pan Law - void setPanLaw(PanLaw law) { panLaw = law; recomputePanGains(); } - PanLaw getPanLaw() const { return panLaw; } + void setPanLaw(PanLaw law) + { + panLaw.store(law, std::memory_order_release); + recomputePanGains(); + } + PanLaw getPanLaw() const + { + return panLaw.load(std::memory_order_acquire); + } // Mute/Solo void setMute(bool shouldMute); @@ -230,19 +266,23 @@ class TrackProcessor : public juce::AudioProcessor void setMIDIInputDevice(const juce::String& device) { midiInputDevice = device; } juce::String getMIDIInputDevice() const { return midiInputDevice; } - void setMIDIChannel(int channel) { midiChannel = juce::jlimit(0, 16, channel); } // 0 = all, 1-16 = specific - int getMIDIChannel() const { return midiChannel; } + void setMIDIChannel(int channel) + { + midiChannel.store( + juce::jlimit(0, 16, channel), + std::memory_order_release); + } // 0 = all, 1-16 = specific + int getMIDIChannel() const + { + return midiChannel.load(std::memory_order_acquire); + } // Instrument plugin (Phase 2) void setInstrument(std::unique_ptr<juce::AudioPluginInstance> plugin, double callerSampleRate = 0.0, int callerBlockSize = 0); void clearInstrument(); juce::AudioPluginInstance* getInstrument() const { return instrumentPlugin.get(); } std::shared_ptr<juce::AudioPluginInstance> getInstrumentShared() const { return instrumentPlugin; } - bool isUsingFallbackInstrument() const - { - return trackType.load(std::memory_order_acquire) == TrackType::Instrument - && std::atomic_load_explicit(&realtimeInstrumentSnapshot, std::memory_order_acquire) == nullptr; - } + bool isUsingFallbackInstrument() const; bool loadFallbackSamplerSample(const juce::String& filePath, int rootNote); void clearFallbackSamplerSample(); bool hasFallbackSamplerSample() const; @@ -293,18 +333,41 @@ class TrackProcessor : public juce::AudioProcessor int getChainLatency() const; void setPDCDelay(int delaySamples); int getPDCDelay() const { return pdcDelaySamples; } + void resetPDCDelayState(); + void resetOfflineRenderState(); + void invalidatePluginAutomationCache() noexcept; // DC Offset Removal - void setDCOffsetRemoval(bool enabled) { dcOffsetRemoval = enabled; } - bool getDCOffsetRemoval() const { return dcOffsetRemoval; } + void setDCOffsetRemoval(bool enabled) + { + dcOffsetRemoval.store(enabled, std::memory_order_release); + } + bool getDCOffsetRemoval() const + { + return dcOffsetRemoval.load(std::memory_order_acquire); + } // Channel Strip EQ — always-available inline parametric EQ (not a plugin slot) - void setChannelStripEQEnabled(bool enabled) { channelStripEQEnabled = enabled; } - bool getChannelStripEQEnabled() const { return channelStripEQEnabled; } + void setChannelStripEQEnabled(bool enabled) + { + channelStripEQEnabled.store(enabled, std::memory_order_release); + channelStripEQ.setPowerEnabled(enabled); + } + bool getChannelStripEQEnabled() const + { + return channelStripEQEnabled.load(std::memory_order_acquire); + } S13EQ* getChannelStripEQ() { return &channelStripEQ; } void setChannelStripEQParam(int paramIndex, float value); float getChannelStripEQParam(int paramIndex) const; + // Stable bridge indices used by the six-control Channel Strip EQ surface. + // These are deliberately independent of AudioProcessor's parameter list so + // the inline strip cannot silently become a no-op when S13EQ is hosted + // directly rather than as a plug-in instance. + static constexpr int channelStripEQBandCount = 6; + static constexpr int channelStripEQValuesPerBand = 4; + // Phase Invert (polarity flip) void setPhaseInvert(bool invert) { phaseInverted.store(invert); } bool getPhaseInvert() const { return phaseInverted.load(); } @@ -332,7 +395,8 @@ class TrackProcessor : public juce::AudioProcessor // Per-track MIDI Output void setMIDIOutputDevice(const juce::String& deviceName); - juce::String getMIDIOutputDeviceName() const { return midiOutputDeviceName; } + juce::String getMIDIOutputDeviceName() const; + bool hasMIDIOutputDevice() const noexcept; void sendMIDIToOutput(const juce::MidiBuffer& buffer, double sampleRate, bool resetMessagesOnly = false); // Automation @@ -380,7 +444,12 @@ class TrackProcessor : public juce::AudioProcessor bool initializeARA(int fxIndex, double sampleRate, int blockSize, std::function<void(bool, bool, const juce::String&)> onComplete = nullptr); // Check if this track has an active ARA session - bool hasActiveARA() const { return araController != nullptr && araController->isActive(); } + bool hasActiveARA() const + { + return araFXIndexForRealtime.load( + std::memory_order_acquire) + >= 0; + } // Get the ARA controller (for adding sources, etc.) ARAHostController* getARAController() { return araController.get(); } int getARAFXIndex() const { return araFXIndex; } @@ -400,12 +469,30 @@ class TrackProcessor : public juce::AudioProcessor void shutdownARA(); private: + // Native deterministic regressions exercise the realtime tail-service + // state machine, including its control-thread timer handoff. + friend class AudioEngine; + friend class NAMDelayRegression; + + struct FallbackSamplerSample; + struct PluginAutomationRoute { juce::String parameterId; bool isInputFX = false; int fxIndex = -1; + // Stable instance identity keeps automation attached to the intended + // processor while graph and route snapshots cross a publication + // boundary during reorder/removal. The callback compares this pointer + // only; the graph snapshot owns the processor for the reader epoch. + const juce::AudioProcessor* targetProcessor = nullptr; int paramIndex = -1; + juce::String builtInParamId; + float builtInMinimum = 0.0f; + float builtInMaximum = 1.0f; + bool builtInDiscrete = false; + OpenStudioBuiltInParameterCurve builtInCurve = + OpenStudioBuiltInParameterCurve::linear; std::shared_ptr<AutomationList> automation = std::make_shared<AutomationList>(); std::atomic<float> lastAppliedValue { std::numeric_limits<float>::quiet_NaN() }; }; @@ -426,19 +513,50 @@ class TrackProcessor : public juce::AudioProcessor bool isInputFX = false; int fxIndex = -1; int paramIndex = -1; + juce::String builtInParamId; }; void processBlockInternal(juce::AudioBuffer<float>&, juce::MidiBuffer&); + void timerCallback() override; + void refreshRealtimeFXTailBudgetOnControlThread(); + void resetExpiredRealtimeFXTailOnControlThread(); void publishRealtimeStateSnapshots(); + void reclaimRetiredRealtimeGraphSnapshots(); + void reclaimRetiredRealtimeAuxOwners(); + void publishFallbackSamplerSample( + std::shared_ptr<const FallbackSamplerSample> sample); + void publishScheduledMIDIClips( + std::shared_ptr<const std::vector<ScheduledMIDIClip>> snapshot); + void reclaimRetiredScheduledMIDISnapshots(); + void resetFXContinuityStates() noexcept; + void publishPluginAutomationRoutes( + std::shared_ptr<const PluginAutomationRouteSnapshot> snapshot); + static std::shared_ptr<PluginAutomationRoute> + clonePluginAutomationRoute(const PluginAutomationRoute& source); + void remapPluginAutomationRoutesForReorder( + bool isInputFX, int fromIndex, int toIndex); + void remapPluginAutomationRoutesForRemoval( + bool isInputFX, int removedIndex); + void publishMIDICCAutomationRoutes( + std::shared_ptr<const MIDICCAutomationRouteSnapshot> snapshot); std::shared_ptr<PluginAutomationRoute> getOrCreatePluginAutomationRoute(const juce::String& parameterId); std::shared_ptr<PluginAutomationRoute> findPluginAutomationRoute(const juce::String& parameterId) const; std::optional<PluginAutomationParameterRef> parsePluginAutomationParameterId(const juce::String& parameterId) const; - void applyPluginAutomationForProcessor(juce::AudioProcessor* proc, bool isInputFX, int fxIndex, double blockTimeSeconds); + void applyPluginAutomationForProcessor( + juce::AudioProcessor* proc, + bool isInputFX, + int fxIndex, + double blockTimeSeconds, + const PluginAutomationRouteSnapshot* routes); std::shared_ptr<MIDICCAutomationRoute> getOrCreateMIDICCAutomationRoute(const juce::String& parameterId); std::shared_ptr<MIDICCAutomationRoute> findMIDICCAutomationRoute(const juce::String& parameterId) const; static std::optional<int> parseMIDICCAutomationParameterId(const juce::String& parameterId); - void applyMIDIAutomationToBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, - int numSamples, double sampleRate); + void applyMIDIAutomationToBuffer( + juce::MidiBuffer& destination, + double blockTimeSeconds, + int numSamples, + double sampleRate, + const MIDICCAutomationRouteSnapshot* ccRoutes); bool shouldApplyAutomation(const AutomationList& automation) const; // Current peak level (was named currentRMS but now holds peak — kept as-is @@ -455,10 +573,14 @@ class TrackProcessor : public juce::AudioProcessor int meterSampleCount { 0 }; float meterPeakAccum { 0.0f }; std::atomic<bool> clipLatched { false }; + // Raw MIDI input activity is deliberately separate from the audio meter. + // It is written by the MIDI input callback and sampled by the UI meter timer. + std::atomic<float> midiInputActivityLevel { 0.0f }; + std::atomic<juce::uint32> midiInputActivityTimestampMs { 0 }; // Recording state (Phase 1) - bool isRecordArmed = false; - bool isRecordSafe = false; // Phase 3.3 — prevents arming + std::atomic<bool> isRecordArmed { false }; + std::atomic<bool> isRecordSafe { false }; // Phase 3.3 — prevents arming std::atomic<bool> isInputMonitoringEnabled { false }; std::atomic<int> inputStartChannel { 0 }; // Hardware input start (0-based) std::atomic<int> inputChannelCount { 2 }; // Stereo by default @@ -473,6 +595,8 @@ class TrackProcessor : public juce::AudioProcessor using SidechainSourceSnapshot = std::map<int, juce::String>; using BypassSnapshot = std::map<int, bool>; using PrecisionOverrideSnapshot = std::map<int, bool>; + static constexpr size_t maxRealtimeFXContinuitySlots = 64; + static constexpr int hostBypassDryChannels = 2; std::vector<ProcessorPtr> inputFXPlugins; // Pre-recording FX std::vector<ProcessorPtr> trackFXPlugins; // Playback FX @@ -488,6 +612,42 @@ class TrackProcessor : public juce::AudioProcessor bool phaseInvert = false; }; using SendSnapshot = std::vector<SendConfig>; + struct FXBypassDelayStorage + { + const juce::AudioProcessor* processor = nullptr; + juce::AudioBuffer<float> ring; + // AudioProcessor::getLatencySamples() reads JUCE's non-atomic latency + // member. Publish the control-thread value explicitly so a NAM model + // load cannot race this realtime dry-history path. + std::atomic<int> publishedLatency { 0 }; + int writePosition = 0; + int currentLatency = 0; + int targetLatency = 0; + int latencyRampRemaining = 0; + int latencyRampLength = 0; + bool latencyInitialised = false; + }; + using FXBypassDelayStoragePtr = + std::shared_ptr<FXBypassDelayStorage>; + struct RealtimeGraphSnapshot + { + uint64 generation = 0; + ProcessorSnapshot inputFX; + ProcessorSnapshot trackFX; + BypassSnapshot inputFXBypass; + BypassSnapshot trackFXBypass; + PrecisionOverrideSnapshot inputFXPrecisionOverrides; + PrecisionOverrideSnapshot trackFXPrecisionOverrides; + ProcessorPtr instrument; + SidechainSourceSnapshot sidechainSources; + SendSnapshot sends; + std::array<FXBypassDelayStoragePtr, + maxRealtimeFXContinuitySlots> + inputFXBypassDelay; + std::array<FXBypassDelayStoragePtr, + maxRealtimeFXContinuitySlots> + trackFXBypassDelay; + }; std::vector<SendConfig> sends; std::map<int, bool> inputFXForceFloatOverrides; std::map<int, bool> trackFXForceFloatOverrides; @@ -499,6 +659,35 @@ class TrackProcessor : public juce::AudioProcessor // than our 2-channel track buffer (avoids heap allocation on audio thread) juce::AudioBuffer<float> fxProcessBuffer; juce::AudioBuffer<double> fxProcessBufferDouble; + struct FXContinuityState + { + const juce::AudioProcessor* processor = nullptr; + uint64 graphGeneration = 0; + std::array<float, 2> lastOutput { 0.0f, 0.0f }; + bool valid = false; + bool skippedLastBlock = false; + float hostBypassWetMix = 1.0f; + bool targetBypassed = false; + std::array<float, 2> endpointCorrection { + 0.0f, 0.0f + }; + std::array<float, 2> endpointCorrectionStep { + 0.0f, 0.0f + }; + int endpointCorrectionSamplesRemaining = 0; + }; + std::array<FXContinuityState, maxRealtimeFXContinuitySlots> + inputFXContinuity {}; + std::array<FXContinuityState, maxRealtimeFXContinuitySlots> + trackFXContinuity {}; + FXContinuityState instrumentContinuity; + // Every loaded slot feeds its latency-aligned dry history continuously so + // an eventual bypass transition starts from valid delayed audio. This + // buffer is separate from fxProcessBuffer because expanded/sidechain + // processing overwrites that buffer before the dry/wet crossfade. + juce::AudioBuffer<float> fxBypassDryBuffer; + float fxBypassRampStep = 1.0f / 882.0f; + int fxContinuityRampSamples = 353; // Sidechain Routing (Phase 4.4) // Maps trackFX plugin index -> source track ID that provides sidechain audio. @@ -514,7 +703,7 @@ class TrackProcessor : public juce::AudioProcessor std::atomic<float> trackPan { 0.0f }; // -1.0 (L) to +1.0 (R) // Pan Law - PanLaw panLaw { PanLaw::Linear }; + std::atomic<PanLaw> panLaw { PanLaw::Linear }; // Cached pan gains — pre-computed in setPan()/setVolume(), avoids trig on audio thread std::atomic<float> cachedPanL { 1.0f }; @@ -524,7 +713,7 @@ class TrackProcessor : public juce::AudioProcessor // Track Type & MIDI (Phase 2) std::atomic<TrackType> trackType { TrackType::Audio }; juce::String midiInputDevice; - int midiChannel = 0; // 0 = all channels, 1-16 = specific channel + std::atomic<int> midiChannel { 0 }; // 0 = all channels, 1-16 = specific channel std::shared_ptr<juce::AudioPluginInstance> instrumentPlugin; juce::MidiBuffer midiBuffer; // For MIDI event storage @@ -548,16 +737,39 @@ class TrackProcessor : public juce::AudioProcessor juce::AudioBuffer<float> automationGainBuffer; juce::CriticalSection pluginAutomationRouteLock; std::shared_ptr<const PluginAutomationRouteSnapshot> pluginAutomationSnapshot; + std::atomic<const PluginAutomationRouteSnapshot*> + pluginAutomationSnapshotForAudio { nullptr }; + // These flags are published after their corresponding immutable snapshot. + // Empty tracks can therefore avoid MSVC's process-wide atomic<shared_ptr> + // lock in the callback; an observed true flag guarantees a visible snapshot. + std::atomic<bool> hasPublishedPluginAutomationRoutes { false }; juce::CriticalSection midiAutomationRouteLock; std::shared_ptr<const MIDICCAutomationRouteSnapshot> midiCCAutomationSnapshot; + std::atomic<const MIDICCAutomationRouteSnapshot*> + midiCCAutomationSnapshotForAudio { nullptr }; + std::atomic<bool> hasPublishedMIDICCAutomationRoutes { false }; + // Automation routes and the fallback sampler are immutable publications. + // A single callback-reader epoch lets their realtime paths load raw + // pointers without entering MSVC's process-wide atomic<shared_ptr> lock. + mutable std::atomic<std::uint32_t> + realtimeAuxAudioReaders { 0 }; + juce::CriticalSection realtimeAuxPublicationLock; + juce::CriticalSection realtimeAuxRetirementLock; + std::vector<std::shared_ptr<const void>> + retiredRealtimeAuxOwners; // Plugin Delay Compensation (PDC) juce::dsp::DelayLine<float> pdcDelayLine { 96000 }; // max 2 seconds at 48kHz std::atomic<int> pdcDelaySamples { 0 }; std::atomic<bool> pdcDelayDirty { false }; + int pdcCurrentDelaySamples = 0; + int pdcTargetDelaySamples = 0; + int pdcPendingDelaySamples = 0; + int pdcTransitionSamplesRemaining = 0; + int pdcTransitionSamplesTotal = 1; // DC Offset Removal - bool dcOffsetRemoval { false }; + std::atomic<bool> dcOffsetRemoval { false }; float dcFilterStateL { 0.0f }; float dcFilterStateR { 0.0f }; float dcPrevInputL { 0.0f }; @@ -565,7 +777,7 @@ class TrackProcessor : public juce::AudioProcessor // Channel Strip EQ S13EQ channelStripEQ; - bool channelStripEQEnabled { false }; + std::atomic<bool> channelStripEQEnabled { false }; // Phase Invert std::atomic<bool> phaseInverted { false }; @@ -590,9 +802,11 @@ class TrackProcessor : public juce::AudioProcessor juce::AudioBuffer<float> preFaderBuffer; // Per-track MIDI Output - juce::String midiOutputDeviceName; - std::unique_ptr<juce::MidiOutput> midiOutputDevice; - juce::MidiBuffer midiOutputResetBuffer; + // The dispatcher object itself is immutable for the TrackProcessor + // lifetime. The callback only writes to its pre-allocated SPSC queue; + // device ownership and operating-system MIDI calls stay on control/sender + // threads. + std::unique_ptr<TrackMIDIOutputDispatcher> midiOutputDispatcher; juce::AudioBuffer<float> realtimeFallbackBuffer; std::atomic<int> realtimeFallbackReuseCount { 0 }; std::atomic<int> pluginBusySkipCount { 0 }; @@ -611,35 +825,54 @@ class TrackProcessor : public juce::AudioProcessor std::atomic<int> lastBuiltMidiEventCount { 0 }; std::atomic<int> maxBuiltMidiEventCount { 0 }; std::atomic<bool> scheduledMIDIChaseRequested { true }; + // Control threads publish only a request. The callback owns + // activeMIDINotes and emits the actual reset messages in-order. + std::atomic<bool> allNotesOffRequested { false }; std::shared_ptr<const std::vector<ScheduledMIDIClip>> scheduledMIDIClips { std::make_shared<const std::vector<ScheduledMIDIClip>>() }; - std::shared_ptr<const ProcessorSnapshot> realtimeInputFXSnapshot { - std::make_shared<const ProcessorSnapshot>() - }; - std::shared_ptr<const ProcessorSnapshot> realtimeTrackFXSnapshot { - std::make_shared<const ProcessorSnapshot>() - }; - std::shared_ptr<const BypassSnapshot> realtimeInputFXBypassSnapshot { - std::make_shared<const BypassSnapshot>() - }; - std::shared_ptr<const BypassSnapshot> realtimeTrackFXBypassSnapshot { - std::make_shared<const BypassSnapshot>() - }; - std::shared_ptr<const PrecisionOverrideSnapshot> realtimeInputFXPrecisionOverrideSnapshot { - std::make_shared<const PrecisionOverrideSnapshot>() - }; - std::shared_ptr<const PrecisionOverrideSnapshot> realtimeTrackFXPrecisionOverrideSnapshot { - std::make_shared<const PrecisionOverrideSnapshot>() - }; - std::shared_ptr<juce::AudioProcessor> realtimeInstrumentSnapshot; - std::shared_ptr<const SidechainSourceSnapshot> realtimeSidechainSnapshot { - std::make_shared<const SidechainSourceSnapshot>() - }; - std::shared_ptr<const SendSnapshot> realtimeSendSnapshot { - std::make_shared<const SendSnapshot>() + std::atomic<const std::vector<ScheduledMIDIClip>*> + scheduledMIDIClipsForAudio { nullptr }; + std::atomic<bool> hasScheduledMIDIClipsForAudio { false }; + mutable std::atomic<std::uint32_t> + scheduledMIDIAudioReaders { 0 }; + juce::CriticalSection scheduledMIDIPublicationLock; + juce::CriticalSection scheduledMIDIRetirementLock; + std::vector<std::shared_ptr<const std::vector<ScheduledMIDIClip>>> + retiredScheduledMIDISnapshots; + // One immutable publication replaces nine independent atomic<shared_ptr> + // loads on every track callback. On MSVC those free-function atomics share + // a process-wide spin lock, so consolidating them materially reduces + // contention at 16/32-sample buffers. + std::shared_ptr<const RealtimeGraphSnapshot> realtimeGraphSnapshot { + std::make_shared<const RealtimeGraphSnapshot>() }; + std::atomic<const RealtimeGraphSnapshot*> + realtimeGraphSnapshotForAudio { nullptr }; + mutable std::atomic<std::uint32_t> + realtimeGraphAudioReaders { 0 }; + juce::CriticalSection realtimeGraphPublicationLock; + juce::CriticalSection realtimeGraphRetirementLock; + std::vector<std::shared_ptr<const RealtimeGraphSnapshot>> + retiredRealtimeGraphSnapshots; + std::atomic<uint64> realtimeGraphGeneration { 0 }; + // Audio tracks that lose their live/playback input still need bounded + // zero-input processing so delays and reverbs drain instead of freezing in + // memory. The callback owns the integer countdowns below; only the small + // publication/request fields are shared with the control-thread timer. + std::atomic<bool> realtimeFXTailActive { false }; + std::atomic<bool> realtimeFXTailResetPending { false }; + std::atomic<uint64> realtimeFXTailActivityGeneration { 0 }; + std::atomic<uint64> realtimeFXTailResetGeneration { 0 }; + std::atomic<int> realtimeFXTailSampleRateHz { 44100 }; + std::atomic<int> realtimeFXTailBudgetSamples { 1367100 }; // 31 s at 44.1 kHz until prepared + std::atomic<int> realtimeFXTailMinimumDrainSamples { 1323000 }; // 30 s at 44.1 kHz + int realtimeFXTailHardSamplesRemaining = 0; + int realtimeFXTailMinimumSamplesRemaining = 0; + int realtimeFXTailQuietSamples = 0; + int realtimeFXTailLastPublishedBudgetSamples = 0; + bool realtimeFXPreviousBlockHadInput = false; std::array<std::array<bool, 128>, 16> activeMIDINotes {}; std::array<std::array<bool, 128>, 16> fallbackInstrumentNoteActive {}; std::array<std::array<bool, 128>, 16> fallbackInstrumentNoteReleasing {}; @@ -681,6 +914,8 @@ class TrackProcessor : public juce::AudioProcessor juce::String filePath; }; std::shared_ptr<const FallbackSamplerSample> fallbackSamplerSample; + std::atomic<const FallbackSamplerSample*> + fallbackSamplerSampleForAudio { nullptr }; ProcessingPrecisionMode processingPrecisionMode { ProcessingPrecisionMode::Float32 }; void markActiveMIDINoteState(const juce::MidiMessage& message); @@ -691,20 +926,35 @@ class TrackProcessor : public juce::AudioProcessor const juce::MidiBuffer& midiMessages, int numSamples, double sampleRate); - void appendScheduledMIDIChaseToBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, - double sampleRate) const; - void appendScheduledMIDIToBuffer(juce::MidiBuffer& destination, double blockTimeSeconds, - int numSamples, double sampleRate) const; + void appendScheduledMIDIChaseToBuffer( + juce::MidiBuffer& destination, + const std::vector<ScheduledMIDIClip>* clips, + double blockTimeSeconds, + double sampleRate) const; + void appendScheduledMIDIToBuffer( + juce::MidiBuffer& destination, + const std::vector<ScheduledMIDIClip>* clips, + double blockTimeSeconds, + int numSamples, + double sampleRate) const; void appendQueuedMIDIToBuffer(juce::MidiBuffer& destination, int numSamples); bool hasQueuedMIDI() const; bool hasScheduledMIDIClips() const; - bool hasScheduledMIDIInBlock(double blockTimeSeconds, int numSamples, double sampleRate) const; + bool hasScheduledMIDIInBlock( + double blockTimeSeconds, + int numSamples, + double sampleRate, + const std::vector<ScheduledMIDIClip>* clips) const; // ARA Plugin Hosting (Phase 9) mutable juce::CriticalSection araStatusLock; std::unique_ptr<ARAHostController> araController; ARAHostController::PlaybackRequestHandlers araPlaybackRequestHandlers; int araFXIndex = -1; // Which FX slot has ARA active (-1 = none) + // The callback must not inspect the control-owned unique_ptr merely to + // discover that ARA is absent. Publish the active slot only after ARA + // initialization completes, and withdraw it before a quiesced shutdown. + std::atomic<int> araFXIndexForRealtime { -1 }; std::atomic<int> araLastAttemptFXIndex { -1 }; std::atomic<bool> araLastAttemptComplete { false }; std::atomic<bool> araLastAttemptWasARAPlugin { false }; diff --git a/Source/TunerPitchTracker.cpp b/Source/TunerPitchTracker.cpp new file mode 100644 index 0000000..58f7efc --- /dev/null +++ b/Source/TunerPitchTracker.cpp @@ -0,0 +1,1567 @@ +#include "TunerPitchTracker.h" + +#include <algorithm> +#include <array> +#include <cmath> +#include <limits> + +namespace +{ +constexpr double kMinimumFrequencyHz = 27.5; +constexpr double kMaximumFrequencyHz = 1320.0; +constexpr double kTargetAnalysisRate = 12000.0; +constexpr int kAnalysisFrameSize = 2048; +constexpr int kAnalysisHopSize = 384; +constexpr int kAcquisitionFrames = 3; +constexpr int kMaximumLagStorage = 1024; +constexpr int kPitchMedianSize = 5; +constexpr float kSignalThresholdDb = -88.0f; +constexpr float kMinimumClarity = 0.68f; +constexpr double kAverageTimeConstantSeconds = 0.16; +constexpr double kSteadyHoldSeconds = 0.45; +constexpr double kReleaseSeconds = 1.20; +constexpr double kTransitionThresholdCents = 80.0; +constexpr double kCandidateConsistencyCents = 32.0; +constexpr double kMaximumWorkerQueueSeconds = 0.25; +constexpr double kFirstAcquisitionWeight = 0.35; + +float safeDecibels(double meanSquare) noexcept +{ + if (! std::isfinite(meanSquare) || meanSquare <= 1.0e-18) + return -120.0f; + + return static_cast<float>( + juce::jlimit(-120.0, 6.0, 10.0 * std::log10(meanSquare))); +} + +double frequencyToAbsoluteCents(double frequencyHz) noexcept +{ + if (! std::isfinite(frequencyHz) || frequencyHz <= 0.0) + return 0.0; + + return 6900.0 + 1200.0 * std::log2(frequencyHz / 440.0); +} + +float absoluteCentsToFrequency(double absoluteCents) noexcept +{ + return static_cast<float>( + 440.0 * std::exp2((absoluteCents - 6900.0) / 1200.0)); +} + +int roundedMidiNote(double absoluteCents) noexcept +{ + return juce::jlimit( + 0, 127, juce::roundToInt(absoluteCents / 100.0)); +} + +double confidenceInfluence(float confidence) noexcept +{ + const double normalized = juce::jlimit( + 0.0, + 1.0, + (static_cast<double>(confidence) + - static_cast<double>(kMinimumClarity)) + / (1.0 - static_cast<double>(kMinimumClarity))); + return 0.20 + 0.80 * normalized * normalized; +} +} + +class TunerPitchTracker::AnalysisCore +{ +public: + explicit AnalysisCore(double newSourceSampleRate) + { + prepare(newSourceSampleRate); + } + + void prepare(double newSourceSampleRate) noexcept + { + sourceSampleRate = std::isfinite(newSourceSampleRate) + && newSourceSampleRate >= 8000.0 + ? newSourceSampleRate + : 48000.0; + + decimationFactor = juce::jlimit( + 1, + 64, + juce::roundToInt(sourceSampleRate / kTargetAnalysisRate)); + analysisSampleRate = + sourceSampleRate / static_cast<double>(decimationFactor); + + const double lowPassCutoffHz = juce::jmin( + 3200.0, analysisSampleRate * 0.38); + lowPassAlpha = static_cast<float>( + 1.0 - std::exp( + -juce::MathConstants<double>::twoPi + * lowPassCutoffHz / sourceSampleRate)); + highPassCoefficient = static_cast<float>( + std::exp( + -juce::MathConstants<double>::twoPi + * 10.0 / sourceSampleRate)); + + reset(); + } + + void reset() noexcept + { + analysisRing.fill(0.0f); + analysisFrame.fill(0.0f); + nsdf.fill(0.0f); + energyPrefix.fill(0.0); + lowPassStates.fill(0.0f); + pitchHistory.fill(0.0); + + ringWriteIndex = 0; + validRingSamples = 0; + samplesSinceAnalysis = 0; + decimationPhase = 0; + inputHighPassX1 = 0.0f; + inputHighPassY1 = 0.0f; + levelSquareSum = 0.0; + levelSampleCount = 0; + totalAnalysisSamples = 0; + lastAcceptedAnalysisSample = 0; + hasAcceptedPitch = false; + acquisitionCount = 0; + acquisitionCandidateCents = 0.0; + acquisitionCandidateWeight = 0.0; + transitionCount = 0; + transitionCandidateCents = 0.0; + pitchHistoryCount = 0; + pitchHistoryWriteIndex = 0; + averagedAbsoluteCents = 0.0; + instantaneousAbsoluteCents = 0.0; + stableMidiNote = -1; + pendingMidiNote = -1; + pendingMidiCount = 0; + lastConfidence = 0.0f; + snapshot = {}; + } + + void process(const float* samples, int numSamples) noexcept + { + if (samples == nullptr || numSamples <= 0) + return; + + for (int sampleIndex = 0; sampleIndex < numSamples; ++sampleIndex) + { + float input = samples[sampleIndex]; + if (! std::isfinite(input)) + input = 0.0f; + + const float highPassed = + input - inputHighPassX1 + + highPassCoefficient * inputHighPassY1; + inputHighPassX1 = input; + inputHighPassY1 = highPassed; + + levelSquareSum += + static_cast<double>(input) + * static_cast<double>(input); + ++levelSampleCount; + + float filtered = highPassed; + for (auto& filterState : lowPassStates) + { + filterState += lowPassAlpha * (filtered - filterState); + filtered = filterState; + } + + ++decimationPhase; + if (decimationPhase < decimationFactor) + continue; + + decimationPhase = 0; + analysisRing[static_cast<size_t>(ringWriteIndex)] = filtered; + ringWriteIndex = + (ringWriteIndex + 1) % kAnalysisFrameSize; + validRingSamples = juce::jmin( + kAnalysisFrameSize, validRingSamples + 1); + ++samplesSinceAnalysis; + ++totalAnalysisSamples; + + if (samplesSinceAnalysis >= kAnalysisHopSize) + { + samplesSinceAnalysis -= kAnalysisHopSize; + analyseCurrentFrame(); + } + } + } + + [[nodiscard]] Snapshot getSnapshot() const noexcept + { + return snapshot; + } + +private: + struct PitchCandidate + { + bool valid = false; + float frequencyHz = 0.0f; + float clarity = 0.0f; + }; + + struct Peak + { + int lag = 0; + float clarity = 0.0f; + }; + + PitchCandidate detectPitch() noexcept + { + PitchCandidate result; + + for (int sample = 0; sample < kAnalysisFrameSize; ++sample) + { + const int sourceIndex = + (ringWriteIndex + sample) % kAnalysisFrameSize; + analysisFrame[static_cast<size_t>(sample)] = + analysisRing[static_cast<size_t>(sourceIndex)]; + } + + energyPrefix[0] = 0.0; + for (int sample = 0; sample < kAnalysisFrameSize; ++sample) + { + const double value = + static_cast<double>( + analysisFrame[static_cast<size_t>(sample)]); + energyPrefix[static_cast<size_t>(sample + 1)] = + energyPrefix[static_cast<size_t>(sample)] + + value * value; + } + + const int minimumLag = juce::jmax( + 2, + static_cast<int>( + std::floor( + analysisSampleRate / kMaximumFrequencyHz))); + const int maximumLag = juce::jlimit( + minimumLag + 2, + kMaximumLagStorage - 2, + static_cast<int>( + std::ceil( + analysisSampleRate / kMinimumFrequencyHz))); + + nsdf.fill(0.0f); + for (int lag = 2; lag <= maximumLag + 1; ++lag) + { + const int comparedSamples = kAnalysisFrameSize - lag; + double cross0 = 0.0; + double cross1 = 0.0; + double cross2 = 0.0; + double cross3 = 0.0; + int sample = 0; + for (; + sample + 3 < comparedSamples; + sample += 4) + { + cross0 += static_cast<double>( + analysisFrame[static_cast<size_t>(sample)]) + * static_cast<double>( + analysisFrame[static_cast<size_t>( + sample + lag)]); + cross1 += static_cast<double>( + analysisFrame[static_cast<size_t>(sample + 1)]) + * static_cast<double>( + analysisFrame[static_cast<size_t>( + sample + lag + 1)]); + cross2 += static_cast<double>( + analysisFrame[static_cast<size_t>(sample + 2)]) + * static_cast<double>( + analysisFrame[static_cast<size_t>( + sample + lag + 2)]); + cross3 += static_cast<double>( + analysisFrame[static_cast<size_t>(sample + 3)]) + * static_cast<double>( + analysisFrame[static_cast<size_t>( + sample + lag + 3)]); + } + double cross = + cross0 + cross1 + cross2 + cross3; + for (; sample < comparedSamples; ++sample) + { + cross += static_cast<double>( + analysisFrame[static_cast<size_t>(sample)]) + * static_cast<double>( + analysisFrame[static_cast<size_t>( + sample + lag)]); + } + + const double energyA = + energyPrefix[static_cast<size_t>(comparedSamples)]; + const double energyB = + energyPrefix[static_cast<size_t>( + comparedSamples + lag)] + - energyPrefix[static_cast<size_t>(lag)]; + const double denominator = energyA + energyB; + if (denominator > 1.0e-18) + { + nsdf[static_cast<size_t>(lag)] = + static_cast<float>( + juce::jlimit( + -1.0, + 1.0, + 2.0 * cross / denominator)); + } + } + + std::array<Peak, kMaximumLagStorage / 2> peaks {}; + int peakCount = 0; + bool crossedNegative = false; + for (int lag = 2; lag <= maximumLag; ++lag) + { + if (nsdf[static_cast<size_t>(lag)] < 0.0f) + crossedNegative = true; + + if (! crossedNegative + || nsdf[static_cast<size_t>(lag)] + <= nsdf[static_cast<size_t>(lag - 1)] + || nsdf[static_cast<size_t>(lag)] + < nsdf[static_cast<size_t>(lag + 1)]) + { + continue; + } + + if (peakCount < static_cast<int>(peaks.size())) + { + peaks[static_cast<size_t>(peakCount)] = { + lag, nsdf[static_cast<size_t>(lag)] + }; + ++peakCount; + } + } + + if (peakCount <= 0) + return result; + + const auto& firstPeak = peaks[0]; + if (firstPeak.lag < minimumLag + && firstPeak.clarity >= 0.92f) + { + return result; + } + + float strongestAllowedPeak = -1.0f; + for (int peakIndex = 0; peakIndex < peakCount; ++peakIndex) + { + const auto& peak = peaks[static_cast<size_t>(peakIndex)]; + if (peak.lag >= minimumLag + && peak.lag <= maximumLag) + { + strongestAllowedPeak = juce::jmax( + strongestAllowedPeak, peak.clarity); + } + } + + if (strongestAllowedPeak < kMinimumClarity) + return result; + + const float relativeThreshold = juce::jmax( + kMinimumClarity, strongestAllowedPeak * 0.90f); + int selectedPeakIndex = -1; + for (int peakIndex = 0; peakIndex < peakCount; ++peakIndex) + { + const auto& peak = peaks[static_cast<size_t>(peakIndex)]; + if (peak.lag >= minimumLag + && peak.lag <= maximumLag + && peak.clarity >= relativeThreshold) + { + selectedPeakIndex = peakIndex; + break; + } + } + + if (selectedPeakIndex < 0) + return result; + + // If a stronger peak at twice the selected period exists, the first + // peak was probably a dominant second/fourth harmonic. Requiring a + // real clarity improvement avoids turning an actual octave change + // into a stale lower-octave lock. + for (int correction = 0; correction < 2; ++correction) + { + const auto selected = + peaks[static_cast<size_t>(selectedPeakIndex)]; + const int doubledLag = selected.lag * 2; + int improvedPeakIndex = -1; + for (int peakIndex = selectedPeakIndex + 1; + peakIndex < peakCount; + ++peakIndex) + { + const auto& peak = + peaks[static_cast<size_t>(peakIndex)]; + if (peak.lag > doubledLag + 2) + break; + if (std::abs(peak.lag - doubledLag) <= 2 + && peak.clarity >= selected.clarity + 0.025f) + { + improvedPeakIndex = peakIndex; + break; + } + } + + if (improvedPeakIndex < 0) + break; + selectedPeakIndex = improvedPeakIndex; + } + + const auto selected = + peaks[static_cast<size_t>(selectedPeakIndex)]; + const float left = + nsdf[static_cast<size_t>(selected.lag - 1)]; + const float centre = + nsdf[static_cast<size_t>(selected.lag)]; + const float right = + nsdf[static_cast<size_t>(selected.lag + 1)]; + const float denominator = + left - 2.0f * centre + right; + float offset = 0.0f; + if (std::abs(denominator) > 1.0e-9f) + { + offset = 0.5f * (left - right) / denominator; + offset = juce::jlimit(-0.5f, 0.5f, offset); + } + + const double refinedLag = + static_cast<double>(selected.lag) + + static_cast<double>(offset); + if (! std::isfinite(refinedLag) || refinedLag <= 0.0) + return result; + + const double frequencyHz = + analysisSampleRate / refinedLag; + if (! std::isfinite(frequencyHz) + || frequencyHz < kMinimumFrequencyHz * 0.995 + || frequencyHz > kMaximumFrequencyHz * 1.005) + { + return result; + } + + result.valid = true; + result.frequencyHz = static_cast<float>(frequencyHz); + result.clarity = juce::jlimit(0.0f, 1.0f, centre); + return result; + } + + void analyseCurrentFrame() noexcept + { + ++snapshot.analysisFrameCounter; + + const double meanSquare = levelSampleCount > 0 + ? levelSquareSum + / static_cast<double>(levelSampleCount) + : 0.0; + snapshot.inputLevelDb = safeDecibels(meanSquare); + snapshot.signalPresent = + snapshot.inputLevelDb >= kSignalThresholdDb; + levelSquareSum = 0.0; + levelSampleCount = 0; + + PitchCandidate candidate; + if (snapshot.signalPresent + && validRingSamples >= kAnalysisFrameSize) + { + candidate = detectPitch(); + } + + if (candidate.valid) + { + handleCandidate(candidate); + } + else + { + handleMissingCandidate(); + } + + updateSnapshotPitchFields(); + } + + void handleCandidate(const PitchCandidate& candidate) noexcept + { + const double candidateCents = + frequencyToAbsoluteCents(candidate.frequencyHz); + if (! std::isfinite(candidateCents)) + { + handleMissingCandidate(); + return; + } + + if (! hasAcceptedPitch) + { + const double candidateWeight = + confidenceInfluence(candidate.clarity); + if (acquisitionCount > 0 + && std::abs( + candidateCents - acquisitionCandidateCents) + <= kCandidateConsistencyCents) + { + const double combinedWeight = + acquisitionCandidateWeight + + candidateWeight; + if (combinedWeight > 0.0) + { + acquisitionCandidateCents += + (candidateCents + - acquisitionCandidateCents) + * candidateWeight + / combinedWeight; + } + acquisitionCandidateWeight = + combinedWeight; + ++acquisitionCount; + } + else + { + // The first usable analysis window can still contain some + // pick energy. Let later consistent windows dominate the + // seed while limiting the extra acquisition cost to one + // analysis hop. + acquisitionCandidateCents = candidateCents; + acquisitionCandidateWeight = + candidateWeight + * kFirstAcquisitionWeight; + acquisitionCount = 1; + } + + snapshot.state = State::acquiring; + snapshot.confidence = candidate.clarity; + if (acquisitionCount >= kAcquisitionFrames) + { + seedAcceptedPitch( + acquisitionCandidateCents, + absoluteCentsToFrequency( + acquisitionCandidateCents), + candidate.clarity); + } + return; + } + + const double distanceFromAverage = + std::abs(candidateCents - averagedAbsoluteCents); + if (distanceFromAverage > kTransitionThresholdCents) + { + if (transitionCount > 0 + && std::abs( + candidateCents - transitionCandidateCents) + <= kCandidateConsistencyCents) + { + transitionCandidateCents = + 0.5 * (transitionCandidateCents + + candidateCents); + ++transitionCount; + } + else + { + transitionCandidateCents = candidateCents; + transitionCount = 1; + } + + const int requiredFrames = + distanceFromAverage >= 700.0 ? 3 : 2; + if (transitionCount >= requiredFrames) + { + seedAcceptedPitch( + transitionCandidateCents, + candidate.frequencyHz, + candidate.clarity); + } + return; + } + + transitionCount = 0; + acceptTrackedPitch( + candidateCents, + candidate.frequencyHz, + candidate.clarity); + } + + void seedAcceptedPitch(double candidateCents, + float frequencyHz, + float confidence) noexcept + { + pitchHistory.fill(candidateCents); + pitchHistoryCount = 1; + pitchHistoryWriteIndex = 1; + averagedAbsoluteCents = candidateCents; + instantaneousAbsoluteCents = candidateCents; + stableMidiNote = roundedMidiNote(candidateCents); + pendingMidiNote = -1; + pendingMidiCount = 0; + acquisitionCount = 0; + acquisitionCandidateWeight = 0.0; + transitionCount = 0; + hasAcceptedPitch = true; + lastAcceptedAnalysisSample = totalAnalysisSamples; + lastConfidence = confidence; + + snapshot.state = State::tracking; + snapshot.pitchLocked = true; + snapshot.instantaneousFrequencyHz = frequencyHz; + snapshot.confidence = confidence; + ++snapshot.pitchUpdateCounter; + } + + void acceptTrackedPitch(double candidateCents, + float frequencyHz, + float confidence) noexcept + { + pitchHistory[ + static_cast<size_t>(pitchHistoryWriteIndex)] = + candidateCents; + pitchHistoryWriteIndex = + (pitchHistoryWriteIndex + 1) % kPitchMedianSize; + pitchHistoryCount = juce::jmin( + kPitchMedianSize, pitchHistoryCount + 1); + + std::array<double, kPitchMedianSize> sorted {}; + for (int index = 0; index < pitchHistoryCount; ++index) + sorted[static_cast<size_t>(index)] = + pitchHistory[static_cast<size_t>(index)]; + std::sort( + sorted.begin(), + sorted.begin() + pitchHistoryCount); + const double medianCents = + sorted[static_cast<size_t>(pitchHistoryCount / 2)]; + + const double hopSeconds = + static_cast<double>(kAnalysisHopSize) + / analysisSampleRate; + const double alpha = juce::jlimit( + 0.02, + 1.0, + 1.0 - std::exp( + -hopSeconds + * confidenceInfluence(confidence) + / kAverageTimeConstantSeconds)); + averagedAbsoluteCents += + (medianCents - averagedAbsoluteCents) * alpha; + instantaneousAbsoluteCents = candidateCents; + updateMidiHysteresis(); + + lastAcceptedAnalysisSample = totalAnalysisSamples; + lastConfidence = confidence; + snapshot.state = State::tracking; + snapshot.pitchLocked = true; + snapshot.instantaneousFrequencyHz = frequencyHz; + snapshot.confidence = confidence; + ++snapshot.pitchUpdateCounter; + } + + void updateMidiHysteresis() noexcept + { + if (stableMidiNote < 0) + { + stableMidiNote = + roundedMidiNote(averagedAbsoluteCents); + return; + } + + const int targetMidi = + roundedMidiNote(averagedAbsoluteCents); + if (targetMidi == stableMidiNote) + { + pendingMidiNote = -1; + pendingMidiCount = 0; + return; + } + + const double centsFromStableCentre = + std::abs( + averagedAbsoluteCents + - static_cast<double>(stableMidiNote) * 100.0); + if (centsFromStableCentre < 58.0) + { + pendingMidiNote = -1; + pendingMidiCount = 0; + return; + } + + if (pendingMidiNote == targetMidi) + { + ++pendingMidiCount; + } + else + { + pendingMidiNote = targetMidi; + pendingMidiCount = 1; + } + + if (pendingMidiCount >= 2) + { + stableMidiNote = targetMidi; + pendingMidiNote = -1; + pendingMidiCount = 0; + } + } + + void handleMissingCandidate() noexcept + { + acquisitionCount = 0; + acquisitionCandidateWeight = 0.0; + transitionCount = 0; + + if (! hasAcceptedPitch) + { + snapshot.state = snapshot.signalPresent + ? State::acquiring + : State::idle; + snapshot.pitchLocked = false; + snapshot.confidence = 0.0f; + return; + } + + const double ageSeconds = + static_cast<double>( + totalAnalysisSamples + - lastAcceptedAnalysisSample) + / analysisSampleRate; + if (ageSeconds >= kReleaseSeconds) + { + clearAcceptedPitch(); + return; + } + + snapshot.state = State::holding; + snapshot.pitchLocked = true; + if (ageSeconds <= kSteadyHoldSeconds) + { + snapshot.confidence = lastConfidence; + } + else + { + const double fadeProgress = juce::jlimit( + 0.0, + 1.0, + (ageSeconds - kSteadyHoldSeconds) + / (kReleaseSeconds + - kSteadyHoldSeconds)); + snapshot.confidence = + lastConfidence + * static_cast<float>( + 1.0 - fadeProgress); + } + } + + void clearAcceptedPitch() noexcept + { + hasAcceptedPitch = false; + pitchHistoryCount = 0; + pitchHistoryWriteIndex = 0; + averagedAbsoluteCents = 0.0; + instantaneousAbsoluteCents = 0.0; + stableMidiNote = -1; + pendingMidiNote = -1; + pendingMidiCount = 0; + lastConfidence = 0.0f; + snapshot.state = State::idle; + snapshot.pitchLocked = false; + snapshot.instantaneousFrequencyHz = 0.0f; + snapshot.averageFrequencyHz = 0.0f; + snapshot.instantaneousCents = 0.0f; + snapshot.averageCents = 0.0f; + snapshot.varianceCents = 0.0f; + snapshot.confidence = 0.0f; + snapshot.midiNote = -1; + snapshot.ageMs = 0.0; + } + + void updateSnapshotPitchFields() noexcept + { + if (! hasAcceptedPitch) + return; + + snapshot.pitchLocked = + snapshot.state == State::tracking + || snapshot.state == State::holding; + snapshot.averageFrequencyHz = + absoluteCentsToFrequency( + averagedAbsoluteCents); + snapshot.midiNote = stableMidiNote; + snapshot.instantaneousCents = + static_cast<float>( + instantaneousAbsoluteCents + - static_cast<double>(stableMidiNote) * 100.0); + snapshot.averageCents = + static_cast<float>( + averagedAbsoluteCents + - static_cast<double>(stableMidiNote) * 100.0); + + double variance = 0.0; + if (pitchHistoryCount > 1) + { + double mean = 0.0; + for (int index = 0; + index < pitchHistoryCount; + ++index) + { + mean += pitchHistory[static_cast<size_t>(index)]; + } + mean /= static_cast<double>(pitchHistoryCount); + + for (int index = 0; + index < pitchHistoryCount; + ++index) + { + const double difference = + pitchHistory[static_cast<size_t>(index)] + - mean; + variance += difference * difference; + } + variance /= static_cast<double>(pitchHistoryCount); + } + snapshot.varianceCents = + static_cast<float>(std::sqrt(variance)); + snapshot.ageMs = + 1000.0 + * static_cast<double>( + totalAnalysisSamples + - lastAcceptedAnalysisSample) + / analysisSampleRate; + } + + double sourceSampleRate = 48000.0; + double analysisSampleRate = 12000.0; + int decimationFactor = 4; + int decimationPhase = 0; + float lowPassAlpha = 0.25f; + float highPassCoefficient = 0.998f; + float inputHighPassX1 = 0.0f; + float inputHighPassY1 = 0.0f; + std::array<float, 4> lowPassStates {}; + + std::array<float, kAnalysisFrameSize> analysisRing {}; + std::array<float, kAnalysisFrameSize> analysisFrame {}; + std::array<float, kMaximumLagStorage> nsdf {}; + std::array<double, kAnalysisFrameSize + 1> energyPrefix {}; + int ringWriteIndex = 0; + int validRingSamples = 0; + int samplesSinceAnalysis = 0; + std::uint64_t totalAnalysisSamples = 0; + + double levelSquareSum = 0.0; + int levelSampleCount = 0; + std::uint64_t lastAcceptedAnalysisSample = 0; + bool hasAcceptedPitch = false; + + int acquisitionCount = 0; + double acquisitionCandidateCents = 0.0; + double acquisitionCandidateWeight = 0.0; + int transitionCount = 0; + double transitionCandidateCents = 0.0; + + std::array<double, kPitchMedianSize> pitchHistory {}; + int pitchHistoryCount = 0; + int pitchHistoryWriteIndex = 0; + double averagedAbsoluteCents = 0.0; + double instantaneousAbsoluteCents = 0.0; + int stableMidiNote = -1; + int pendingMidiNote = -1; + int pendingMidiCount = 0; + float lastConfidence = 0.0f; + Snapshot snapshot; +}; + +TunerPitchTracker::TunerPitchTracker() + : juce::Thread("NAM tuner pitch analysis"), + fifo(std::make_unique< + std::array<FifoSample, fifoCapacity>>()) +{ +} + +TunerPitchTracker::~TunerPitchTracker() +{ + stopWorker(); +} + +void TunerPitchTracker::prepare(double sourceSampleRate, + int maximumBlockSize) +{ + const bool wasEnabled = + enabled.exchange(false, std::memory_order_acq_rel); + stopWorker(); + + testingMode.store(false, std::memory_order_release); + preparedSampleRate = + std::isfinite(sourceSampleRate) + && sourceSampleRate >= 8000.0 + ? sourceSampleRate + : 48000.0; + preparedMaximumBlockSize = + juce::jmax(1, maximumBlockSize); + juce::ignoreUnused(preparedMaximumBlockSize); + const int decimationFactor = juce::jlimit( + 1, + 64, + juce::roundToInt( + preparedSampleRate / kTargetAnalysisRate)); + const int firstCompleteAnalysisFrame = + ((kAnalysisFrameSize + kAnalysisHopSize - 1) + / kAnalysisHopSize) + * kAnalysisHopSize; + const auto minimumFreshWindowSamples = + static_cast<std::uint32_t>( + (firstCompleteAnalysisFrame + + (kAcquisitionFrames - 1) + * kAnalysisHopSize) + * decimationFactor); + const auto latencyWindowSamples = + static_cast<std::uint32_t>( + juce::roundToInt( + preparedSampleRate + * kMaximumWorkerQueueSeconds)); + maximumQueuedSamples = juce::jlimit( + 1u, + fifoCapacity, + juce::jmax( + minimumFreshWindowSamples, + latencyWindowSamples)); + analysisCore = + std::make_unique<AnalysisCore>(preparedSampleRate); + + fifoReadCounter.store(0, std::memory_order_relaxed); + fifoWriteCounter.store(0, std::memory_order_relaxed); + droppedFifoSamples.store(0, std::memory_order_relaxed); + producerOverflowed.store(false, std::memory_order_relaxed); + resetProducerSelection(); + const auto generation = + routeGeneration.fetch_add( + 1, std::memory_order_acq_rel) + 1; + publishSnapshot({}, generation); + + startThread(juce::Thread::Priority::low); + enabled.store(wasEnabled, std::memory_order_release); + if (wasEnabled) + notify(); +} + +void TunerPitchTracker::setEnabled(bool shouldBeEnabled) noexcept +{ + if (testingMode.load(std::memory_order_acquire)) + { + enabled.store(shouldBeEnabled, std::memory_order_release); + if (! shouldBeEnabled && analysisCore != nullptr) + { + analysisCore->reset(); + const auto generation = + routeGeneration.fetch_add( + 1, std::memory_order_acq_rel) + 1; + publishSnapshot({}, generation); + } + return; + } + + const bool previous = + enabled.exchange( + shouldBeEnabled, std::memory_order_acq_rel); + if (previous == shouldBeEnabled) + return; + + routeGeneration.fetch_add( + 1, std::memory_order_acq_rel); + notify(); +} + +bool TunerPitchTracker::isEnabled() const noexcept +{ + return enabled.load(std::memory_order_acquire); +} + +std::uint32_t +TunerPitchTracker::resetForRouteChange() noexcept +{ + selectedChannel.store(-1, std::memory_order_relaxed); + const auto generation = + routeGeneration.fetch_add( + 1, std::memory_order_acq_rel) + 1; + + if (testingMode.load(std::memory_order_acquire) + && analysisCore != nullptr) + { + analysisCore->reset(); + publishSnapshot({}, generation); + return generation; + } + + notify(); + return generation; +} + +std::uint32_t +TunerPitchTracker::getGenerationToken() const noexcept +{ + return routeGeneration.load(std::memory_order_acquire); +} + +void TunerPitchTracker::pushAudio(const float* const* channels, + int numChannels, + int numSamples, + std::uint32_t expectedGeneration) noexcept +{ + if (! enabled.load(std::memory_order_relaxed) + || testingMode.load(std::memory_order_relaxed) + || numSamples <= 0) + { + return; + } + + const auto currentGeneration = + routeGeneration.load(std::memory_order_acquire); + const auto generation = + expectedGeneration != 0 + ? expectedGeneration + : currentGeneration; + if (generation != currentGeneration) + return; + + if (producerGeneration != generation) + { + producerSelectedChannel = -1; + producerGeneration = generation; + } + + if (channels == nullptr || numChannels <= 0) + { + selectedChannel.store(-1, std::memory_order_relaxed); + writeToFifo(nullptr, numSamples, generation); + return; + } + + const int channel = + chooseStrongestChannel( + channels, numChannels, numSamples); + if (channel < 0 || channels[channel] == nullptr) + { + selectedChannel.store(-1, std::memory_order_relaxed); + writeToFifo(nullptr, numSamples, generation); + return; + } + + writeToFifo( + channels[channel], numSamples, generation); + if (routeGeneration.load(std::memory_order_relaxed) + == generation) + { + selectedChannel.store( + channel, std::memory_order_relaxed); + } +} + +void TunerPitchTracker::pushSilence( + int numSamples, + std::uint32_t expectedGeneration) noexcept +{ + if (! enabled.load(std::memory_order_relaxed) + || testingMode.load(std::memory_order_relaxed) + || numSamples <= 0) + { + return; + } + + const auto currentGeneration = + routeGeneration.load(std::memory_order_acquire); + const auto generation = + expectedGeneration != 0 + ? expectedGeneration + : currentGeneration; + if (generation != currentGeneration) + return; + + if (producerGeneration != generation) + { + producerSelectedChannel = -1; + producerGeneration = generation; + } + selectedChannel.store(-1, std::memory_order_relaxed); + writeToFifo(nullptr, numSamples, generation); +} + +void TunerPitchTracker::writeToFifo( + const float* source, + int numSamples, + std::uint32_t generation) noexcept +{ + const auto write = + fifoWriteCounter.load(std::memory_order_relaxed); + const auto read = + fifoReadCounter.load(std::memory_order_acquire); + const auto used = write - read; + const auto requested = + static_cast<std::uint32_t>(numSamples); + if (used > fifoCapacity + || requested > fifoCapacity - used) + { + droppedFifoSamples.fetch_add( + requested, std::memory_order_relaxed); + producerOverflowed.store( + true, std::memory_order_release); + return; + } + + for (std::uint32_t sample = 0; + sample < requested; + ++sample) + { + const float value = + source != nullptr ? source[sample] : 0.0f; + (*fifo)[static_cast<size_t>( + (write + sample) & fifoMask)] = { + std::isfinite(value) ? value : 0.0f, + generation + }; + } + + fifoWriteCounter.store( + write + requested, std::memory_order_release); +} + +TunerPitchTracker::Snapshot +TunerPitchTracker::getSnapshot() const noexcept +{ + Snapshot result; + result.enabled = + enabled.load(std::memory_order_acquire); + if (! result.enabled) + return result; + + std::uint32_t publishedGeneration = 0; + bool coherent = false; + for (int attempt = 0; attempt < 12; ++attempt) + { + const auto sequenceBefore = + published.sequence.load(std::memory_order_acquire); + if ((sequenceBefore & 1u) != 0u) + continue; + + publishedGeneration = + published.generation.load(std::memory_order_relaxed); + result.state = static_cast<State>( + published.state.load(std::memory_order_relaxed)); + result.signalPresent = + published.signalPresent.load(std::memory_order_relaxed); + result.pitchLocked = + published.pitchLocked.load(std::memory_order_relaxed); + result.instantaneousFrequencyHz = + published.instantaneousFrequencyHz.load( + std::memory_order_relaxed); + result.averageFrequencyHz = + published.averageFrequencyHz.load( + std::memory_order_relaxed); + result.instantaneousCents = + published.instantaneousCents.load( + std::memory_order_relaxed); + result.averageCents = + published.averageCents.load( + std::memory_order_relaxed); + result.varianceCents = + published.varianceCents.load( + std::memory_order_relaxed); + result.confidence = + published.confidence.load( + std::memory_order_relaxed); + result.inputLevelDb = + published.inputLevelDb.load( + std::memory_order_relaxed); + result.midiNote = + published.midiNote.load(std::memory_order_relaxed); + result.selectedChannel = + published.selectedChannel.load( + std::memory_order_relaxed); + result.pitchUpdateCounter = + published.pitchUpdateCounter.load( + std::memory_order_relaxed); + result.analysisFrameCounter = + published.analysisFrameCounter.load( + std::memory_order_relaxed); + result.droppedFifoSamples = + published.droppedFifoSamples.load( + std::memory_order_relaxed); + result.ageMs = + published.ageMs.load(std::memory_order_relaxed); + + const auto sequenceAfter = + published.sequence.load(std::memory_order_acquire); + if (sequenceBefore == sequenceAfter + && (sequenceAfter & 1u) == 0u) + { + coherent = true; + break; + } + } + + const auto currentGeneration = + routeGeneration.load(std::memory_order_acquire); + if (! coherent + || publishedGeneration != currentGeneration) + { + Snapshot idle; + idle.enabled = true; + idle.selectedChannel = + selectedChannel.load(std::memory_order_relaxed); + idle.droppedFifoSamples = + droppedFifoSamples.load(std::memory_order_relaxed); + return idle; + } + + return result; +} + +void TunerPitchTracker::prepareForTesting(double sourceSampleRate) +{ + enabled.store(false, std::memory_order_release); + stopWorker(); + + testingMode.store(true, std::memory_order_release); + preparedSampleRate = + std::isfinite(sourceSampleRate) + && sourceSampleRate >= 8000.0 + ? sourceSampleRate + : 48000.0; + analysisCore = + std::make_unique<AnalysisCore>(preparedSampleRate); + droppedFifoSamples.store(0, std::memory_order_relaxed); + producerOverflowed.store(false, std::memory_order_relaxed); + resetProducerSelection(); + const auto generation = + routeGeneration.fetch_add( + 1, std::memory_order_acq_rel) + 1; + enabled.store(true, std::memory_order_release); + publishSnapshot({}, generation); +} + +void TunerPitchTracker::processAudioForTesting( + const float* const* channels, + int numChannels, + int numSamples) noexcept +{ + if (! testingMode.load(std::memory_order_acquire) + || ! enabled.load(std::memory_order_acquire) + || analysisCore == nullptr + || channels == nullptr + || numChannels <= 0 + || numSamples <= 0) + { + return; + } + + const auto generation = + routeGeneration.load(std::memory_order_relaxed); + if (producerGeneration != generation) + { + producerSelectedChannel = -1; + producerGeneration = generation; + } + + const int channel = + chooseStrongestChannel( + channels, numChannels, numSamples); + if (channel < 0 || channels[channel] == nullptr) + return; + + selectedChannel.store( + channel, std::memory_order_relaxed); + analysisCore->process(channels[channel], numSamples); + auto result = analysisCore->getSnapshot(); + result.enabled = true; + result.selectedChannel = channel; + result.droppedFifoSamples = 0; + publishSnapshot( + result, + routeGeneration.load(std::memory_order_acquire)); +} + +void TunerPitchTracker::processMonoForTesting( + const float* samples, + int numSamples) noexcept +{ + const float* channels[] { samples }; + processAudioForTesting(channels, 1, numSamples); +} + +void TunerPitchTracker::run() +{ + std::uint32_t workerGeneration = 0; + + while (! threadShouldExit()) + { + if (! enabled.load(std::memory_order_acquire)) + { + wait(-1.0); + continue; + } + + const auto currentGeneration = + routeGeneration.load(std::memory_order_acquire); + if (workerGeneration != currentGeneration) + { + workerGeneration = currentGeneration; + const auto write = + fifoWriteCounter.load(std::memory_order_acquire); + fifoReadCounter.store(write, std::memory_order_release); + if (analysisCore != nullptr) + { + analysisCore->reset(); + auto resetSnapshot = + analysisCore->getSnapshot(); + resetSnapshot.enabled = true; + resetSnapshot.selectedChannel = + selectedChannel.load( + std::memory_order_relaxed); + resetSnapshot.droppedFifoSamples = + droppedFifoSamples.load( + std::memory_order_relaxed); + publishSnapshot( + resetSnapshot, workerGeneration); + } + } + + if (producerOverflowed.exchange( + false, std::memory_order_acq_rel)) + { + // Once incoming callbacks have been dropped, the remaining FIFO + // no longer represents the newest live audio. Discard it rather + // than displaying a pitch from several seconds in the past. + const auto write = + fifoWriteCounter.load(std::memory_order_acquire); + fifoReadCounter.store( + write, std::memory_order_release); + if (analysisCore != nullptr) + { + analysisCore->reset(); + auto resetSnapshot = + analysisCore->getSnapshot(); + resetSnapshot.enabled = true; + resetSnapshot.selectedChannel = + selectedChannel.load( + std::memory_order_relaxed); + resetSnapshot.droppedFifoSamples = + droppedFifoSamples.load( + std::memory_order_relaxed); + publishSnapshot( + resetSnapshot, workerGeneration); + } + continue; + } + + auto read = + fifoReadCounter.load(std::memory_order_relaxed); + const auto write = + fifoWriteCounter.load(std::memory_order_acquire); + auto available = write - read; + if (available == 0) + { + wait(4); + continue; + } + + if (available > maximumQueuedSamples) + { + const auto staleSamples = + available - maximumQueuedSamples; + read += staleSamples; + available -= staleSamples; + fifoReadCounter.store( + read, std::memory_order_release); + droppedFifoSamples.fetch_add( + staleSamples, std::memory_order_relaxed); + + // Keeping the old temporal state after fast-forwarding would + // combine a stale pitch with the newest audio. Reacquire from + // the retained recent window instead. + if (analysisCore != nullptr) + { + analysisCore->reset(); + auto resetSnapshot = + analysisCore->getSnapshot(); + resetSnapshot.enabled = true; + resetSnapshot.selectedChannel = + selectedChannel.load( + std::memory_order_relaxed); + resetSnapshot.droppedFifoSamples = + droppedFifoSamples.load( + std::memory_order_relaxed); + publishSnapshot( + resetSnapshot, workerGeneration); + } + } + + const auto toRead = juce::jmin( + available, + static_cast<std::uint32_t>( + workerScratchCapacity)); + int matchingSamples = 0; + for (std::uint32_t sample = 0; + sample < toRead; + ++sample) + { + const auto& fifoSample = + (*fifo)[static_cast<size_t>( + (read + sample) & fifoMask)]; + if (fifoSample.generation + == workerGeneration) + { + workerScratch[static_cast<size_t>( + matchingSamples)] = + fifoSample.value; + ++matchingSamples; + } + } + fifoReadCounter.store( + read + toRead, std::memory_order_release); + + if (analysisCore == nullptr + || matchingSamples <= 0) + continue; + + analysisCore->process( + workerScratch.data(), + matchingSamples); + + if (! enabled.load(std::memory_order_acquire) + || routeGeneration.load(std::memory_order_acquire) + != workerGeneration) + { + continue; + } + + auto result = analysisCore->getSnapshot(); + result.enabled = true; + result.selectedChannel = + selectedChannel.load(std::memory_order_relaxed); + result.droppedFifoSamples = + droppedFifoSamples.load(std::memory_order_relaxed); + publishSnapshot(result, workerGeneration); + } +} + +void TunerPitchTracker::stopWorker() noexcept +{ + if (! isThreadRunning()) + return; + + signalThreadShouldExit(); + notify(); + stopThread(2000); +} + +void TunerPitchTracker::publishSnapshot( + const Snapshot& snapshot, + std::uint32_t generation) noexcept +{ + published.sequence.fetch_add( + 1, std::memory_order_acq_rel); + published.generation.store( + generation, std::memory_order_relaxed); + published.state.store( + static_cast<int>(snapshot.state), + std::memory_order_relaxed); + published.signalPresent.store( + snapshot.signalPresent, std::memory_order_relaxed); + published.pitchLocked.store( + snapshot.pitchLocked, std::memory_order_relaxed); + published.instantaneousFrequencyHz.store( + snapshot.instantaneousFrequencyHz, + std::memory_order_relaxed); + published.averageFrequencyHz.store( + snapshot.averageFrequencyHz, + std::memory_order_relaxed); + published.instantaneousCents.store( + snapshot.instantaneousCents, + std::memory_order_relaxed); + published.averageCents.store( + snapshot.averageCents, + std::memory_order_relaxed); + published.varianceCents.store( + snapshot.varianceCents, + std::memory_order_relaxed); + published.confidence.store( + snapshot.confidence, std::memory_order_relaxed); + published.inputLevelDb.store( + snapshot.inputLevelDb, std::memory_order_relaxed); + published.midiNote.store( + snapshot.midiNote, std::memory_order_relaxed); + published.selectedChannel.store( + snapshot.selectedChannel, std::memory_order_relaxed); + published.pitchUpdateCounter.store( + snapshot.pitchUpdateCounter, + std::memory_order_relaxed); + published.analysisFrameCounter.store( + snapshot.analysisFrameCounter, + std::memory_order_relaxed); + published.droppedFifoSamples.store( + snapshot.droppedFifoSamples, + std::memory_order_relaxed); + published.ageMs.store( + snapshot.ageMs, std::memory_order_relaxed); + published.sequence.fetch_add( + 1, std::memory_order_release); +} + +void TunerPitchTracker::resetProducerSelection() noexcept +{ + producerSelectedChannel = -1; + producerGeneration = + routeGeneration.load(std::memory_order_relaxed); + selectedChannel.store(-1, std::memory_order_relaxed); +} + +int TunerPitchTracker::chooseStrongestChannel( + const float* const* channels, + int numChannels, + int numSamples) noexcept +{ + if (channels == nullptr + || numChannels <= 0 + || numSamples <= 0) + { + return -1; + } + + constexpr int maximumScannedChannels = 8; + const int scannedChannels = juce::jmin( + numChannels, maximumScannedChannels); + int strongestChannel = -1; + double strongestEnergy = -1.0; + double currentEnergy = -1.0; + + for (int channel = 0; + channel < scannedChannels; + ++channel) + { + const auto* source = channels[channel]; + if (source == nullptr) + continue; + + double energy = 0.0; + for (int sample = 0; sample < numSamples; ++sample) + { + const float value = source[sample]; + if (std::isfinite(value)) + { + energy += + static_cast<double>(value) + * static_cast<double>(value); + } + } + + if (channel == producerSelectedChannel) + currentEnergy = energy; + if (energy > strongestEnergy) + { + strongestEnergy = energy; + strongestChannel = channel; + } + } + + if (producerSelectedChannel >= 0 + && producerSelectedChannel < scannedChannels + && channels[producerSelectedChannel] != nullptr + && currentEnergy >= 0.0 + && (strongestEnergy <= 0.0 + || currentEnergy >= strongestEnergy * 0.65)) + { + strongestChannel = producerSelectedChannel; + } + + producerSelectedChannel = strongestChannel; + return strongestChannel; +} diff --git a/Source/TunerPitchTracker.h b/Source/TunerPitchTracker.h new file mode 100644 index 0000000..1d5aae7 --- /dev/null +++ b/Source/TunerPitchTracker.h @@ -0,0 +1,159 @@ +#pragma once + +#include <JuceHeader.h> + +#include <array> +#include <atomic> +#include <cstdint> +#include <memory> + +/** + * Real-time-safe input pitch tracker used by the NAM Rack tuner. + * + * The audio callback only selects one input channel and copies it into a + * preallocated SPSC FIFO. Downsampling, pitch detection, temporal tracking, + * and display averaging all run on the worker thread. + */ +class TunerPitchTracker final : private juce::Thread +{ +public: + enum class State : std::uint8_t + { + idle = 0, + acquiring, + tracking, + holding + }; + + struct Snapshot + { + bool enabled = false; + bool signalPresent = false; + bool pitchLocked = false; + State state = State::idle; + + float instantaneousFrequencyHz = 0.0f; + float averageFrequencyHz = 0.0f; + float instantaneousCents = 0.0f; + float averageCents = 0.0f; + float varianceCents = 0.0f; + float confidence = 0.0f; + float inputLevelDb = -120.0f; + + int midiNote = -1; + int selectedChannel = -1; + + std::uint64_t pitchUpdateCounter = 0; + std::uint64_t analysisFrameCounter = 0; + std::uint32_t droppedFifoSamples = 0; + double ageMs = 0.0; + }; + + TunerPitchTracker(); + ~TunerPitchTracker() override; + + void prepare(double sourceSampleRate, int maximumBlockSize); + void setEnabled(bool shouldBeEnabled) noexcept; + [[nodiscard]] bool isEnabled() const noexcept; + [[nodiscard]] std::uint32_t + resetForRouteChange() noexcept; + [[nodiscard]] std::uint32_t + getGenerationToken() const noexcept; + + /** + * Audio-thread producer entry point. This method does not allocate, lock, + * wait, log, or perform pitch analysis. + */ + void pushAudio(const float* const* channels, + int numChannels, + int numSamples, + std::uint32_t expectedGeneration = 0) noexcept; + void pushSilence( + int numSamples, + std::uint32_t expectedGeneration = 0) noexcept; + + [[nodiscard]] Snapshot getSnapshot() const noexcept; + + /** + * Deterministic synchronous mode for native regression probes. It drives + * the same downsampler, detector, state machine, and averaging code as the + * worker without sleeping or depending on wall-clock time. + */ + void prepareForTesting(double sourceSampleRate); + void processAudioForTesting(const float* const* channels, + int numChannels, + int numSamples) noexcept; + void processMonoForTesting(const float* samples, + int numSamples) noexcept; + +private: + class AnalysisCore; + + static constexpr std::uint32_t fifoCapacity = 1u << 18; + static constexpr std::uint32_t fifoMask = fifoCapacity - 1u; + static constexpr int workerScratchCapacity = 4096; + + struct FifoSample + { + float value = 0.0f; + std::uint32_t generation = 0; + }; + + struct PublishedState + { + std::atomic<std::uint32_t> sequence { 0 }; + std::atomic<std::uint32_t> generation { 0 }; + std::atomic<int> state { + static_cast<int>(State::idle) + }; + std::atomic<bool> signalPresent { false }; + std::atomic<bool> pitchLocked { false }; + std::atomic<float> instantaneousFrequencyHz { 0.0f }; + std::atomic<float> averageFrequencyHz { 0.0f }; + std::atomic<float> instantaneousCents { 0.0f }; + std::atomic<float> averageCents { 0.0f }; + std::atomic<float> varianceCents { 0.0f }; + std::atomic<float> confidence { 0.0f }; + std::atomic<float> inputLevelDb { -120.0f }; + std::atomic<int> midiNote { -1 }; + std::atomic<int> selectedChannel { -1 }; + std::atomic<std::uint64_t> pitchUpdateCounter { 0 }; + std::atomic<std::uint64_t> analysisFrameCounter { 0 }; + std::atomic<std::uint32_t> droppedFifoSamples { 0 }; + std::atomic<double> ageMs { 0.0 }; + }; + + void run() override; + void stopWorker() noexcept; + void publishSnapshot(const Snapshot& snapshot, + std::uint32_t generation) noexcept; + void resetProducerSelection() noexcept; + int chooseStrongestChannel(const float* const* channels, + int numChannels, + int numSamples) noexcept; + void writeToFifo(const float* source, + int numSamples, + std::uint32_t generation) noexcept; + + std::unique_ptr<std::array<FifoSample, fifoCapacity>> fifo; + std::array<float, workerScratchCapacity> workerScratch {}; + std::atomic<std::uint32_t> fifoReadCounter { 0 }; + std::atomic<std::uint32_t> fifoWriteCounter { 0 }; + std::atomic<std::uint32_t> droppedFifoSamples { 0 }; + std::atomic<bool> producerOverflowed { false }; + std::atomic<std::uint32_t> routeGeneration { 1 }; + std::atomic<bool> enabled { false }; + std::atomic<bool> testingMode { false }; + std::atomic<int> selectedChannel { -1 }; + + int producerSelectedChannel = -1; + std::uint32_t producerGeneration = 0; + double preparedSampleRate = 48000.0; + int preparedMaximumBlockSize = 512; + std::uint32_t maximumQueuedSamples = 12000; + + std::unique_ptr<AnalysisCore> analysisCore; + PublishedState published; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(TunerPitchTracker) +}; diff --git a/Source/VideoReader.cpp b/Source/VideoReader.cpp index edc86f3..82db013 100644 --- a/Source/VideoReader.cpp +++ b/Source/VideoReader.cpp @@ -1,4 +1,5 @@ #include "VideoReader.h" +#include "FFmpegLocator.h" VideoReader::VideoReader() { @@ -12,14 +13,7 @@ VideoReader::~VideoReader() juce::File VideoReader::findFFmpeg() const { - // Look next to the executable first - auto exeDir = juce::File::getSpecialLocation(juce::File::currentExecutableFile).getParentDirectory(); - auto ffmpeg = exeDir.getChildFile("ffmpeg.exe"); - if (ffmpeg.existsAsFile()) - return ffmpeg; - - // Try PATH - return juce::File("ffmpeg"); + return OpenStudioFFmpeg::findExecutable(); } bool VideoReader::openFile(const juce::String& filePath, const juce::File& audioOutputDir) @@ -58,18 +52,26 @@ void VideoReader::closeFile() bool VideoReader::parseMetadata(const juce::String& filePath) { - if (!ffmpegExe.existsAsFile() && ffmpegExe.getFullPathName() != "ffmpeg") + if (! ffmpegExe.existsAsFile()) return false; // Use ffmpeg -i to get metadata (writes to stderr) juce::ChildProcess proc; - juce::String cmd = "\"" + ffmpegExe.getFullPathName() + "\" -i \"" + filePath + "\" -hide_banner"; + juce::StringArray args; + args.add(ffmpegExe.getFullPathName()); + args.add("-i"); + args.add(filePath); + args.add("-hide_banner"); - if (!proc.start(cmd)) + if (! proc.start(args)) return false; // ffmpeg -i exits with error code 1 but writes metadata to stderr - proc.waitForProcessToFinish(10000); + if (! proc.waitForProcessToFinish(10000)) + { + proc.kill(); + return false; + } juce::String output = proc.readAllProcessOutput(); // Parse duration: "Duration: HH:MM:SS.ms" @@ -136,20 +138,33 @@ bool VideoReader::parseMetadata(const juce::String& filePath) bool VideoReader::extractAudio(const juce::String& videoPath, const juce::File& outputWav) { - if (!ffmpegExe.existsAsFile() && ffmpegExe.getFullPathName() != "ffmpeg") + if (! ffmpegExe.existsAsFile()) return false; if (outputWav.existsAsFile()) outputWav.deleteFile(); - juce::String cmd = "\"" + ffmpegExe.getFullPathName() + "\" -i \"" + videoPath + - "\" -vn -acodec pcm_s24le -ar 48000 -y \"" + outputWav.getFullPathName() + "\""; + juce::StringArray args; + args.add(ffmpegExe.getFullPathName()); + args.add("-i"); + args.add(videoPath); + args.add("-vn"); + args.add("-acodec"); + args.add("pcm_s24le"); + args.add("-ar"); + args.add("48000"); + args.add("-y"); + args.add(outputWav.getFullPathName()); juce::ChildProcess proc; - if (!proc.start(cmd)) + if (! proc.start(args)) return false; - proc.waitForProcessToFinish(60000); // Up to 60 seconds + if (! proc.waitForProcessToFinish(60000)) + { + proc.kill(); + return false; + } return outputWav.existsAsFile(); } @@ -158,7 +173,7 @@ juce::String VideoReader::getFrameAtTime(double timeSeconds, int outputWidth, in if (!fileOpen || info.filePath.isEmpty()) return {}; - if (!ffmpegExe.existsAsFile() && ffmpegExe.getFullPathName() != "ffmpeg") + if (! ffmpegExe.existsAsFile()) return {}; // Extract a single frame as JPEG to a temp file @@ -168,15 +183,30 @@ juce::String VideoReader::getFrameAtTime(double timeSeconds, int outputWidth, in juce::String timeStr = juce::String(timeSeconds, 3); juce::String scaleFilter = "scale=" + juce::String(outputWidth) + ":" + juce::String(outputHeight); - juce::String cmd = "\"" + ffmpegExe.getFullPathName() + "\" -ss " + timeStr + - " -i \"" + info.filePath + "\" -vf \"" + scaleFilter + - "\" -frames:v 1 -q:v 2 -y \"" + tempFile.getFullPathName() + "\""; + juce::StringArray args; + args.add(ffmpegExe.getFullPathName()); + args.add("-ss"); + args.add(timeStr); + args.add("-i"); + args.add(info.filePath); + args.add("-vf"); + args.add(scaleFilter); + args.add("-frames:v"); + args.add("1"); + args.add("-q:v"); + args.add("2"); + args.add("-y"); + args.add(tempFile.getFullPathName()); juce::ChildProcess proc; - if (!proc.start(cmd)) + if (! proc.start(args)) return {}; - proc.waitForProcessToFinish(5000); + if (! proc.waitForProcessToFinish(5000)) + { + proc.kill(); + return {}; + } if (!tempFile.existsAsFile()) return {}; diff --git a/Source/VideoReader.h b/Source/VideoReader.h index 136f1b2..6b0fc72 100644 --- a/Source/VideoReader.h +++ b/Source/VideoReader.h @@ -36,7 +36,7 @@ class VideoReader // Uses FFmpeg to seek and extract one frame juce::String getFrameAtTime(double timeSeconds, int outputWidth = 320, int outputHeight = 180); - // Set the FFmpeg executable path (defaults to adjacent ffmpeg.exe) + // Set the FFmpeg executable path (defaults to app candidates, then PATH) void setFFmpegPath(const juce::File& path) { ffmpegExe = path; } private: diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 70df37b..f73d5dc 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -4,7 +4,7 @@ OpenStudio uses the following open-source libraries and dependencies. --- -## JUCE 8.0.0 +## JUCE 9.0.1 - **Website:** https://juce.com/ - **License:** AGPLv3 / Commercial @@ -26,14 +26,20 @@ OpenStudio is released under AGPLv3-compatible terms. Licensed under the Apache License, Version 2.0. You may obtain a copy at: http://www.apache.org/licenses/LICENSE-2.0 -YSFX bundles portions of WDL (Cockos) under the WTFPL license. +The exact upstream Apache-2.0 text is shipped in every application bundle as +`licenses/YSFX-LICENSE.txt`. + +YSFX bundles portions of WDL (Cockos) under WDL's zlib-style license and uses +dr_libs and stb under their upstream dual-license terms. Their exact notices +are shipped as `licenses/WDL-LICENSE.txt`, `licenses/dr_libs-LICENSE.txt`, and +`licenses/stb-LICENSE.txt`. --- ## WDL (Cockos) - **Website:** https://www.cockos.com/wdl/ -- **License:** WTFPL (Do What The F*** You Want To Public License) +- **License:** zlib-style WDL license - **Copyright:** (c) Cockos Incorporated - **Usage:** Bundled with YSFX for EEL2 compilation and DSP primitives @@ -74,13 +80,26 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. ## FFmpeg - **Website:** https://ffmpeg.org/ -- **License:** LGPLv2.1+ / GPLv2+ +- **Bundled Windows build:** FFmpeg 8.0.1 essentials build from Gyan Doshi +- **License for this build:** GPL-3.0-or-later - **Copyright:** (c) The FFmpeg developers - **Usage:** Audio format conversion (MP3, OGG, etc.) via external process -FFmpeg is distributed as a standalone executable and is not linked into -the OpenStudio binary. It is invoked as a child process for lossy format -encoding and sample rate conversion. +On Windows, FFmpeg is distributed as a standalone executable and is not linked +into the OpenStudio binary. It is invoked as a child process for lossy format +encoding and sample rate conversion. The exact binary checksum, build +configuration, provider, and upstream source archive are recorded in +`licenses/FFmpeg-PROVENANCE.json`; the applicable GPLv3 text is shipped as +`licenses/FFmpeg-COPYING.GPLv3.txt`. + +OpenStudio does not redistribute an FFmpeg binary in its macOS or Linux +packages. Those builds use an optional system `ffmpeg` on `PATH` when present. + +The bundled Windows executable is a static GPL build with GPL-enabled and +third-party libraries. Public binary distribution therefore also requires +complete corresponding source for the exact linked build under the applicable +licenses. The FFmpeg core source link alone does not cover every statically +linked dependency. --- @@ -110,8 +129,10 @@ The React frontend uses packages installed via npm. Key dependencies include: | Lucide React | ISC | Icon library | | @dnd-kit | MIT | Drag-and-drop toolkit | -For a complete list of frontend dependencies and their licenses, see -`frontend/package.json` and run `npx license-checker` in the frontend directory. +The complete deterministic production dependency inventory and exact installed +license/notice texts are generated from `frontend/package-lock.json` as +`frontend/THIRD_PARTY_NOTICES.txt`. Every application bundle ships that file as +`licenses/Frontend-THIRD_PARTY_NOTICES.txt`. --- @@ -122,6 +143,69 @@ For a complete list of frontend dependencies and their licenses, see - **Copyright:** (c) free-audio contributors - **Usage:** CLAP plugin format hosting headers +The exact upstream MIT text is shipped in every application bundle as +`licenses/CLAP-LICENSE.txt`. + +--- + +## NeuralAmpModelerCore + +- **Website:** https://github.com/sdatkinson/NeuralAmpModelerCore +- **Version:** 0.5.4 +- **License:** MIT License +- **Copyright:** NeuralAmpModelerCore contributors +- **Usage:** NAM/A1/A2 neural amp model loading and DSP processing + +The complete upstream MIT text is shipped with each application bundle as +`licenses/NeuralAmpModelerCore-LICENSE.txt`. + +OpenStudio preserves TONE3000 per-tone license metadata in saved NAM tones. +TONE3000 models and thumbnails are not redistributed by OpenStudio. + +--- + +## Eigen + +- **Website:** https://gitlab.com/libeigen/eigen +- **Version:** 5.0.1 (`bc3b39870ecb690a623a3f49149a358b95c5781d`) +- **License:** Mozilla Public License 2.0, with individual files under + compatible BSD, Apache-2.0, and MINPACK notices +- **Usage:** Header-only matrix operations used by NeuralAmpModelerCore + +Eigen is primarily licensed under the Mozilla Public License 2.0. The exact +source used by OpenStudio is available through NeuralAmpModelerCore's +`Dependencies/eigen` submodule at the commit above. The complete MPL-2.0, +BSD, Apache-2.0, MINPACK, and explanatory `COPYING.*` files are shipped in the +application bundle's `licenses/` directory. + +--- + +## JSON for Modern C++ (nlohmann/json) + +- **Website:** https://github.com/nlohmann/json +- **Version:** 3.12.0 +- **License:** MIT License +- **Copyright:** Copyright (c) 2013-2025 Niels Lohmann +- **Usage:** Header-only NAM model JSON parsing through NeuralAmpModelerCore + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + --- ## Signalsmith Stretch @@ -131,19 +215,57 @@ For a complete list of frontend dependencies and their licenses, see - **Copyright:** (c) Signalsmith Audio Ltd - **Usage:** Pitch shifting with formant preservation (header-only library) -Includes signalsmith-linear (FFT/STFT), also MIT licensed. +Includes signalsmith-linear (FFT/STFT), also MIT licensed. The exact +Signalsmith Stretch and Signalsmith Linear MIT texts are shipped in every +application bundle as `licenses/Signalsmith-Stretch-LICENSE.txt` and +`licenses/Signalsmith-Linear-LICENSE.txt`. + +--- + +## terrarium-poly-octave + +- **Website:** https://github.com/schult/terrarium-poly-octave +- **License:** MIT License +- **Copyright:** (c) 2024 Steven Schulteis +- **Usage:** Reference implementation and equations adapted for the NAM Rack's + stereo ERB phase-scaling octave generator + +OpenStudio retains the complete upstream MIT notice in +`Source/NAMPolyOctaver.cpp`. Its desktop implementation is sample-rate aware, +uses independent left/right state, and does not include the upstream Daisy, +Q, or GCEM dependencies. + +--- + +## Spotify Basic Pitch ICASSP 2022 model + +- **Website:** https://github.com/spotify/basic-pitch +- **Upstream version:** v0.4.0, commit + `9991303bba609a3b93089d13ec80d1d495083596` +- **Upstream file:** `basic_pitch/saved_models/icassp_2022/nmp.onnx` +- **SHA-256:** + `2c3c1d144bfa61ad236e92e169c13535c880469a12a047d4e73451f2c059a0ec` +- **License:** Apache License 2.0 +- **Copyright:** Copyright 2022 Spotify AB +- **Usage:** Polyphonic pitch detection and audio-to-MIDI transcription + +OpenStudio ships the unchanged official 230,444-byte ONNX model. Its pinned +provenance manifest is bundled beside the model, and the exact upstream +`LICENSE` and `NOTICE` files are shipped in the application's `licenses/` +directory. --- -## ONNX Runtime (Optional) +## ONNX Runtime 1.24.4 (Optional) - **Website:** https://onnxruntime.ai/ - **License:** MIT License - **Copyright:** (c) Microsoft Corporation - **Usage:** Neural network inference for polyphonic pitch detection (Basic-Pitch model) -Pre-built binary; not compiled from source. See `thirdparty/onnxruntime/ThirdPartyNotices.txt` -for full third-party notices. +Pre-built binary; not compiled from source. When ONNX Runtime is included, its +exact upstream `LICENSE` and `ThirdPartyNotices.txt` files are shipped in the +application bundle's `licenses/` directory. --- @@ -154,7 +276,13 @@ for full third-party notices. - **Copyright:** (c) Celemony Software GmbH - **Usage:** ARA 2 plugin hosting (Melodyne, SpectraLayers integration) -Includes nested dependencies: cpp-base64 (zlib), pugixml (MIT). +The vendored SDK notice also identifies dependencies used by its examples, +including cpp-base64 (zlib) and pugixml (MIT). OpenStudio compiles the ARA API +and ARA Library host support, not the SDK example applications. + +The exact vendored ARA notice plus the ARA API and ARA Library Apache-2.0 +license texts are shipped as `licenses/ARA-NOTICE.txt`, +`licenses/ARA-API-LICENSE.txt`, and `licenses/ARA-Library-LICENSE.txt`. --- @@ -165,6 +293,9 @@ Includes nested dependencies: cpp-base64 (zlib), pugixml (MIT). - **Copyright:** (c) David Reid - **Usage:** Audio format decoding (WAV, MP3, FLAC) — header-only +The exact upstream dual-license text used by the YSFX dependency is shipped as +`licenses/dr_libs-LICENSE.txt`. + --- ## stb @@ -173,3 +304,6 @@ Includes nested dependencies: cpp-base64 (zlib), pugixml (MIT). - **License:** MIT License / Public Domain (dual choice) - **Copyright:** (c) Sean Barrett - **Usage:** Image I/O utilities — header-only + +The exact upstream dual-license text used by the YSFX dependency is shipped as +`licenses/stb-LICENSE.txt`. diff --git a/WORKFLOWS.md b/WORKFLOWS.md index 17d8c5c..c62e75a 100644 --- a/WORKFLOWS.md +++ b/WORKFLOWS.md @@ -1,4 +1,4 @@ -# Studio13 Development Workflows +# OpenStudio Development Workflows ## ✨ NEW: Single Command Development @@ -10,7 +10,7 @@ python build.py dev --run 1. ✅ Installs npm dependencies 2. ✅ Builds C++ backend (if needed) 3. ✅ Starts Vite dev server (background) -4. ✅ Launches Studio13_v2.exe +4. ✅ Launches OpenStudio.exe 5. ✅ Auto-cleanup when you close the app **No more juggling terminals!** @@ -27,7 +27,7 @@ Before that handoff: - `cmake --build build --config Debug` must have completed after the latest changes. - The current frontend must be built/copied so packaged assets are not stale if fallback is ever used. - No pre-running Vite/npm/dev server should be required from the user. -- Any Codex-started Vite/npm/browser harness processes should be stopped first, and port `5173` should not be left occupied by a Codex-started process. +- Any Codex-started Vite/npm/browser harness processes should be stopped first, and port `5183` should not be left occupied by a Codex-started process. **Run this to rebuild the backend:** @@ -51,7 +51,7 @@ cd frontend npm run dev # Terminal 2 -./build/Studio13_v2_artefacts/Debug/Studio13.exe +./build/OpenStudio_artefacts/Debug/OpenStudio.exe ``` --- @@ -60,9 +60,10 @@ npm run dev ```bash python build.py prod +doppler run -- python build.py dev --run ``` -**Output:** Single executable at `build/Studio13_v2_artefacts/Release/Studio13.exe` +**Output:** Single executable at `build/OpenStudio_artefacts/Release/OpenStudio.exe` **No Vite needed!** Assets are embedded. --- @@ -82,15 +83,24 @@ git push origin ai-runtime-v0.0.31 ``` -## This command needs to be ran on macOS after installation through installer to un-quarantine the app and use it -## Otherwise the app would be shown as damaged or broken in macOS +## macOS first launch + +The normal first-launch path is: + +1. Verify the downloaded DMG against the published SHA-256 checksum. +2. Drag `OpenStudio.app` to `/Applications` and attempt to open it. +3. If macOS blocks the unsigned build, use **System Settings > Privacy & + Security > Open Anyway** for that app, then confirm the launch. + +## If that also doesn't work, then run this command to un-quarantine the app and use it +## Otherwise the app might be shown as damaged or broken in macOS ```bash xattr -dr com.apple.quarantine /Applications/OpenStudio.app ``` ## Comparison with REAPER -| Feature | Studio13 (Hybrid) | REAPER (Native) | +| Feature | OpenStudio (Hybrid) | REAPER (Native) | |---------|-------------------|-----------------| | **Dev Mode** | `python build.py dev --run` | Rebuild for every UI change | | **UI Tech** | React + CSS | Win32/Cocoa C++ | diff --git a/build.py b/build.py index ebf989c..d86a02b 100644 --- a/build.py +++ b/build.py @@ -13,7 +13,33 @@ vite_process = None cpp_process = None -VITE_DEV_URL = "http://127.0.0.1:5173" +VITE_DEV_HOST = "127.0.0.1" +VITE_DEV_PORT = 5183 +VITE_DEV_URL = f"http://{VITE_DEV_HOST}:{VITE_DEV_PORT}" +TONE3000_CLIENT_ID_ENV_NAMES = ( + "TONE3000_PUBLISHABLE_KEY", + "OPENSTUDIO_TONE3000_CLIENT_ID_VALUE", + "OPENSTUDIO_TONE3000_CLIENT_ID", +) + + +def get_first_env_value(names): + for name in names: + value = os.environ.get(name, "").strip() + if value: + return name, value + return "", "" + + +def mask_command_for_log(command): + if isinstance(command, (list, tuple)): + command = subprocess.list2cmdline([str(part) for part in command]) + else: + command = str(command) + _, tone3000_client_id = get_first_env_value(TONE3000_CLIENT_ID_ENV_NAMES) + if tone3000_client_id: + command = command.replace(tone3000_client_id, "<tone3000_publishable_key>") + return command def kill_process_tree(pid): """Kill a process and all its child processes (Windows: taskkill /T)""" @@ -67,12 +93,15 @@ def cleanup(): # Register cleanup handler atexit.register(cleanup) -def run_command(command, cwd=None, shell=True): - print(f"Running: {command}") +def run_command(command, cwd=None, shell=None): + if shell is None: + shell = isinstance(command, str) + print(f"Running: {mask_command_for_log(command)}") try: subprocess.check_call(command, cwd=cwd, shell=shell) - except subprocess.CalledProcessError as e: - print(f"Error running command: {command}") + except (OSError, subprocess.CalledProcessError) as error: + print(f"Error running command: {mask_command_for_log(command)}") + print(error) sys.exit(1) def get_npm_executable(): @@ -141,16 +170,20 @@ def build_backend(mode="debug"): # Configure CMake. On single-config generators (Linux/macOS Make/Ninja) # CMAKE_BUILD_TYPE must be set at configure time, not just at build time. - cmd = f"cmake -B \"{build_dir}\"" + cmd = ["cmake", "-B", build_dir] if platform.system() != "Windows": - cmd += f" -DCMAKE_BUILD_TYPE={config_type}" - if mode == "debug": - cmd += " -DJUCE_DEBUG=ON" + cmd.append(f"-DCMAKE_BUILD_TYPE={config_type}") + tone3000_env_name, tone3000_client_id = get_first_env_value(TONE3000_CLIENT_ID_ENV_NAMES) + if tone3000_client_id: + print(f"Using TONE3000 publishable client_id from ${tone3000_env_name}.") + cmd.append(f"-DOPENSTUDIO_TONE3000_CLIENT_ID_VALUE={tone3000_client_id}") + else: + print("No TONE3000 publishable client_id env var found; keeping the existing CMake cache value if one is set.") - run_command(cmd) + run_command(cmd, shell=False) # Build - run_command(f"cmake --build \"{build_dir}\" --config {config_type}") + run_command(["cmake", "--build", build_dir, "--config", config_type], shell=False) def start_vite_server(): """Start Vite dev server in background""" @@ -158,7 +191,17 @@ def start_vite_server(): frontend_dir = os.path.join(os.getcwd(), "frontend") print("\n--- Starting Vite Dev Server ---") vite_process = subprocess.Popen( - [get_npm_executable(), "run", "dev", "--", "--host", "127.0.0.1", "--strictPort"], + [ + get_npm_executable(), + "run", + "dev", + "--", + "--host", + VITE_DEV_HOST, + "--port", + str(VITE_DEV_PORT), + "--strictPort", + ], cwd=frontend_dir, shell=False ) diff --git a/built_in_plugin_upgrade_status.md b/built_in_plugin_upgrade_status.md deleted file mode 100644 index fbe0d32..0000000 --- a/built_in_plugin_upgrade_status.md +++ /dev/null @@ -1,154 +0,0 @@ -# Built-In Plugin Upgrade Status - -This file tracks the remaining work for the Studio13 built-in plugin suite. Items marked done are implemented in the current working tree, but subjective sound quality still needs user audition before it can be called final. - -## Completed - -- [x] Add a built-in plugin schema bridge for React editors. -- [x] Add bridge methods for built-in plugin schema, state, parameter set, and state set. -- [x] Add React schema-driven built-in plugin panel inside `FXChainPanel`. -- [x] Keep external VST/CLAP/LV2 editors native while built-ins use React panels. -- [x] Fix dev frontend fallback/HMR mismatch by using `127.0.0.1:5173` consistently. -- [x] Make dev mode rebuild packaged frontend fallback assets. -- [x] Add bundled built-in instrument plugins addable from the FX/plugin chain: - - [x] `Studio13 Basic Synth` - - [x] `Studio13 Piano` - - [x] `Studio13 Drums` -- [x] Set tracks to `instrument` when a built-in instrument plugin is added. -- [x] Prevent the old fallback instrument from double-rendering when a built-in instrument FX exists. -- [x] Preserve built-in FX/instrument save/load by storing/restoring built-in plugin names. -- [x] Preserve built-in FX/instrument duplication by restoring built-ins through the built-in FX bridge. -- [x] Improve Basic Synth from a simple fallback tone to a polyphonic subtractive synth with anti-aliased oscillators, sub, noise, brightness, detune, attack, release, and output gain. -- [x] Add Basic Synth pitch-bend and mod-wheel routing. -- [x] Add a playable synthesized piano instrument with tone, body, hammer, release, and output gain controls. -- [x] Improve piano toward hybrid-modeled behavior with model flavors, sustain pedal, resonance, and stereo width. -- [x] Add a playable synthesized drum instrument with kit, tuning, room, hi-hat tightness, output gain, GM notes, and CC4 hi-hat pedal behavior for e-drums. -- [x] Improve drums toward hybrid-modeled behavior with velocity curve, punch, and stereo kit placement controls. -- [x] Improve sampler fallback interpolation from linear to cubic. -- [x] Remove selected audio-thread temporary allocations in built-in reverb and saturator paths. -- [x] Make compressor auto makeup apply adaptive makeup gain from current gain reduction. -- [x] Make compressor auto release adapt release time from current gain reduction. -- [x] Replace limiter hard-clip end stage with a preallocated lookahead gain stage and soft safety ceiling. -- [x] Add intersample-aware peak estimation to limiter detection. -- [x] Add EQ auto-gain based on the actual filter response instead of a decorative toggle. -- [x] Add compressor Peak/RMS/Auto detector modes. -- [x] Add compressor stereo-link detector control. -- [x] Add 4x oversampled true-peak detection into limiter gain reduction. -- [x] Add gate Peak/RMS/Auto detector modes. -- [x] Avoid constant gate sidechain filter coefficient rebuilds when filter values have not changed. -- [x] Smooth delay-time changes to avoid abrupt delay jumps. -- [x] Correct delay tempo-sync note mapping to match the UI labels. -- [x] Add delay ducking control. -- [x] Add tape-style delay modulation in the feedback path. -- [x] Add reverb early reflection taps independent from late-tail level. -- [x] Cache reverb wet tone filter coefficients instead of rebuilding them every block. -- [x] Replace stock reverb late-tail processing with a native 8-line feedback delay network. -- [x] Add denser reverb late-tail diffusion with algorithm-specific delay spacing. -- [x] Add reverb late-tail modulation, width shaping, freeze feedback, and shimmer-style feedback coloration. -- [x] Expose EQ magnitude response and pre/post analyzer snapshots through the built-in schema. -- [x] Add draggable EQ response graph editing in the React built-in panel. -- [x] Add EQ band audition parameter with DSP/schema/state support. -- [x] Add EQ dynamic band parameters with detector-driven gain modulation and UI visualization. -- [x] Add EQ stereo/mid/side processing mode where stereo routing supports it. -- [x] Cache EQ band parameter state to avoid rebuilding unchanged filter coefficients every audio block. -- [x] Add built-in plugin offline DSP smoke fixture to the native automated regression suite. -- [x] Add compressor sidechain HPF regression fixture comparing low-frequency gain reduction at 20 Hz vs 500 Hz HPF. -- [x] Expose dynamics gain-reduction, input/output level, and gate-open metrics through the built-in schema. -- [x] Add live dynamics gain-reduction history and meters in the React built-in panel. -- [x] Expose pitch-correct live pitch telemetry/history through the built-in schema and React panel. -- [x] Make chorus tempo sync affect LFO rate. -- [x] Add chorus/flanger/phaser character modes for Clean, Ensemble, and BBD-style modulation. -- [x] Apply chorus/flanger/phaser wet low-cut and high-cut filters. -- [x] Preallocate separate saturator 2x and 4x oversamplers and choose quality mode without audio-thread allocation. -- [x] Add saturator drive output compensation. -- [x] Add Console, Transformer, and Foldback saturator models. -- [x] Add saturator post-drive low-cut tone filter. -- [x] Remove remaining reverb audio-thread dry/early scratch resizing by preallocating larger process buffers in `prepareToPlay`. -- [x] Replace raw built-in parameter rows with plugin-aware macro controls, grouped sections, and responsive editor grids. -- [x] Add built-in latency/tail and delay smoothing regression fixtures. -- [x] Remove per-sample phaser all-pass coefficient allocation and cache dynamic EQ detector coefficients. -- [x] Add frontend Vitest coverage for built-in schema classification, primary controls, and React control rendering. -- [x] Make existing `ParametricGraph` controls theme-aware with unique clip paths and reusable graph color tokens. -- [x] Add smoothing for saturator drive, mix, and compensated output gain. -- [x] Wire the built-in EQ editor to the reusable schema-driven `ParametricGraph` adapter with response and analyzer curves. -- [x] Add responsive layout guards for every built-in panel kind across desktop, tablet, and narrow CSS contracts. - -## Remaining DSP Work - -- [x] EQ: add analyzer pre/post display. -- [x] EQ: add draggable response graph editing. -- [x] EQ: add dynamic bands. -- [x] EQ: add band audition. -- [x] EQ: add stereo/mid-side modes where routing supports it. -- [x] EQ: implement real auto-gain or remove misleading auto-gain behavior. -- [x] Compressor: add peak/RMS/auto detector modes. -- [x] Compressor: add stereo link control. -- [x] Compressor: complete working auto release and auto makeup behavior. -- [x] Compressor: add sidechain HPF behavior that is audibly and measurably effective. -- [x] Compressor: add gain-reduction history for UI. -- [x] Gate: add detector mode and sidechain filter polish. -- [x] Gate: add gain-reduction/open-close history for UI. -- [x] Limiter: add lookahead ring buffer if current path is insufficient. -- [x] Limiter: add oversampled true-peak detection/limiting. -- [x] Limiter: avoid hard-clip distortion at ceiling. -- [x] Delay: smooth delay-time changes. -- [x] Delay: add dotted/triplet tempo sync polish. -- [x] Delay: add ducking. -- [x] Delay: improve modulation, width, tone, and saturation in feedback. -- [x] Reverb: replace current wrapper-level behavior with stronger native room/hall/plate algorithms. -- [x] Reverb: add early reflections. -- [x] Reverb: add denser late tail. -- [x] Reverb: improve damping, modulation, width, freeze, and shimmer behavior. -- [x] Chorus/flanger/phaser: add interpolated delay lines. -- [x] Chorus/flanger/phaser: add ensemble/BBD character modes. -- [x] Chorus/flanger/phaser: improve tone filters, tempo sync, stereo spread, and modulation quality. -- [x] Saturator: complete oversampling quality modes without realtime allocations. -- [x] Saturator: add more modeled curves, bias/asymmetry polish, tone filters, and output compensation. -- [x] Pitch Correct: finish unified built-in UI/schema polish and deterministic routing tests. -- [x] Piano: improve from synthesized decent to sample-library-grade or hybrid modeled/sample playback. -- [x] Drums: improve from synthesized decent to sample-library-grade or hybrid modeled/sample playback. -- [x] Drums: add named e-drum mapping presets, including Roland TD-style mappings. - -## Remaining UI Work - -- [x] Replace generic schema layout with polished plugin-specific React editors for each built-in. -- [x] Add professional DAW-style graph controls for EQ, dynamics, delay, reverb, modulation, and saturation. -- [x] Refactor existing `ParametricGraph` controls into fully schema-driven graph adapters. -- [x] Add meters/history visualizations for dynamics, limiter, and gate. -- [x] Add analyzer visualization for EQ. -- [x] Add instrument-specific visual polish for synth, piano, and drums. -- [x] Check responsive layouts at desktop and narrow widths for every built-in panel. - -## Remaining Realtime Safety Work - -- [x] Audit all built-ins for audio-thread heap allocations. -- [x] Preallocate analyzer, delay, oversampling, scratch, and tail buffers in `prepareToPlay`. -- [x] Add parameter smoothing where zipper noise can occur. -- [x] Add denormal, NaN, and bounded-output guards across all built-ins. -- [x] Avoid expensive coefficient rebuilds inside audio callbacks where possible. - -## Remaining Tests - -- [x] Add offline C++ DSP harnesses for built-ins. -- [x] Test bypass parity. -- [x] Test finite output and no NaN/Inf. -- [x] Test bounded gain. -- [x] Test latency and tail behavior. -- [x] Test parameter smoothing and zipper-spike avoidance. -- [x] Test EQ curves. -- [x] Test compressor, gate, and limiter gain behavior. -- [x] Test limiter true-peak handling. -- [x] Test delay tempo timing. -- [x] Test reverb tail decay. -- [x] Test synth tuning, MIDI note handling, voice stealing, pitch bend, and mod behavior. -- [x] Test piano MIDI behavior and voice cleanup. -- [x] Test drum GM/Roland-style note mapping and CC4 hi-hat behavior. -- [x] Add frontend tests for built-in schema mapping and React controls. -- [x] Verify all new `useDAWStore` consumers use `useShallow`. - -## Acceptance Status - -- Objective build/type verification: pass as of the latest implementation pass. -- Objective routing/schema behavior: pass for the implemented built-in instrument/React bridge foundation. -- Subjective audio quality: not_asserted until user auditions exact artifacts in the app. -- Industry-standard parity for the full built-in suite: implementation pass complete, subjective audition pending. diff --git a/cmake/ApplyJUCERealtimePatches.cmake b/cmake/ApplyJUCERealtimePatches.cmake new file mode 100644 index 0000000..1174400 --- /dev/null +++ b/cmake/ApplyJUCERealtimePatches.cmake @@ -0,0 +1,310 @@ +if(NOT DEFINED JUCE_SOURCE_DIR) + message(FATAL_ERROR "JUCE_SOURCE_DIR was not provided") +endif() + +set(JUCE_ASIO_DEVICE_SOURCE + "${JUCE_SOURCE_DIR}/modules/juce_audio_devices/native/juce_ASIO_windows.cpp") +if(NOT EXISTS "${JUCE_ASIO_DEVICE_SOURCE}") + message(FATAL_ERROR + "JUCE ASIO device source was not found: ${JUCE_ASIO_DEVICE_SOURCE}") +endif() + +file(READ "${JUCE_ASIO_DEVICE_SOURCE}" JUCE_ASIO_SOURCE) + +set(JUCE_ASIO_UNPATCHED_GETTER +" int getXRunCount() const noexcept override { return xruns; }") +set(JUCE_ASIO_PATCHED_GETTER +" int getXRunCount() const noexcept override + { + return xruns.load (std::memory_order_relaxed); + }") +set(JUCE_ASIO_UNPATCHED_DISABLED_WRITE +" xruns = -1;") +set(JUCE_ASIO_PATCHED_DISABLED_WRITE +" xruns.store (-1, std::memory_order_relaxed);") +set(JUCE_ASIO_UNPATCHED_DECLARATION +" int xruns = 0;") +set(JUCE_ASIO_PATCHED_DECLARATION +" std::atomic<int> xruns { 0 };") +set(JUCE_ASIO_UNPATCHED_RESET +" xruns = 0;") +set(JUCE_ASIO_PATCHED_RESET +" xruns.store (0, std::memory_order_relaxed);") +set(JUCE_ASIO_UNPATCHED_INCREMENT +" case kAsioOverload: ++xruns; return 1;") +set(JUCE_ASIO_PATCHED_INCREMENT +" case kAsioOverload: + xruns.fetch_add (1, std::memory_order_relaxed); + return 1;") + +string(FIND + "${JUCE_ASIO_SOURCE}" + "${JUCE_ASIO_PATCHED_DECLARATION}" + JUCE_ASIO_ATOMIC_PATCHED_AT) +if(JUCE_ASIO_ATOMIC_PATCHED_AT GREATER_EQUAL 0) + foreach(PATCHED_SNIPPET + JUCE_ASIO_PATCHED_GETTER + JUCE_ASIO_PATCHED_DISABLED_WRITE + JUCE_ASIO_PATCHED_RESET + JUCE_ASIO_PATCHED_INCREMENT) + string(FIND + "${JUCE_ASIO_SOURCE}" + "${${PATCHED_SNIPPET}}" + JUCE_ASIO_PATCHED_SNIPPET_AT) + if(JUCE_ASIO_PATCHED_SNIPPET_AT LESS 0) + message(FATAL_ERROR + "JUCE ASIO x-run counter patch is incomplete at ${PATCHED_SNIPPET}") + endif() + endforeach() + message(STATUS "JUCE ASIO atomic x-run counter patch is already applied") +else() + foreach(UNPATCHED_SNIPPET + JUCE_ASIO_UNPATCHED_GETTER + JUCE_ASIO_UNPATCHED_DISABLED_WRITE + JUCE_ASIO_UNPATCHED_DECLARATION + JUCE_ASIO_UNPATCHED_RESET + JUCE_ASIO_UNPATCHED_INCREMENT) + string(FIND + "${JUCE_ASIO_SOURCE}" + "${${UNPATCHED_SNIPPET}}" + JUCE_ASIO_UNPATCHED_SNIPPET_AT) + if(JUCE_ASIO_UNPATCHED_SNIPPET_AT LESS 0) + message(FATAL_ERROR + "Pinned JUCE ASIO x-run counter patch context changed at ${UNPATCHED_SNIPPET}; refusing an unverified dependency rewrite") + endif() + endforeach() + + string(REPLACE + "${JUCE_ASIO_UNPATCHED_GETTER}" + "${JUCE_ASIO_PATCHED_GETTER}" + JUCE_ASIO_SOURCE + "${JUCE_ASIO_SOURCE}") + string(REPLACE + "${JUCE_ASIO_UNPATCHED_DISABLED_WRITE}" + "${JUCE_ASIO_PATCHED_DISABLED_WRITE}" + JUCE_ASIO_SOURCE + "${JUCE_ASIO_SOURCE}") + string(REPLACE + "${JUCE_ASIO_UNPATCHED_DECLARATION}" + "${JUCE_ASIO_PATCHED_DECLARATION}" + JUCE_ASIO_SOURCE + "${JUCE_ASIO_SOURCE}") + string(REPLACE + "${JUCE_ASIO_UNPATCHED_RESET}" + "${JUCE_ASIO_PATCHED_RESET}" + JUCE_ASIO_SOURCE + "${JUCE_ASIO_SOURCE}") + string(REPLACE + "${JUCE_ASIO_UNPATCHED_INCREMENT}" + "${JUCE_ASIO_PATCHED_INCREMENT}" + JUCE_ASIO_SOURCE + "${JUCE_ASIO_SOURCE}") + file(WRITE + "${JUCE_ASIO_DEVICE_SOURCE}" + "${JUCE_ASIO_SOURCE}") + message(STATUS "Applied JUCE ASIO atomic x-run counter patch") +endif() + +set(JUCE_BUFFERING_READER + "${JUCE_SOURCE_DIR}/modules/juce_audio_formats/format/juce_BufferingAudioFormatReader.cpp") +if(NOT EXISTS "${JUCE_BUFFERING_READER}") + message(FATAL_ERROR + "JUCE BufferingAudioFormatReader source was not found: ${JUCE_BUFFERING_READER}") +endif() + +file(READ "${JUCE_BUFFERING_READER}" JUCE_BUFFERING_READER_SOURCE) + +set(JUCE_BLOCKING_LOCK +" const ScopedLock sl (lock); + nextReadPosition = startSampleInFile;") + +set(JUCE_REALTIME_LOCK_V1 +" // OpenStudio uses timeoutMs == 0 from its realtime playback callback. + // A normal ScopedLock can priority-invert against the low-priority + // read-ahead thread. In realtime mode, report a cache miss instead of + // waiting; non-realtime callers retain JUCE's original blocking behavior. + const bool realtimeTryOnly = timeoutMs == 0; + if (realtimeTryOnly) + { + if (! lock.tryEnter()) + { + for (int channel = 0; channel < numDestChannels; ++channel) + if (auto* dest = reinterpret_cast<float*> (destSamples[channel])) + FloatVectorOperations::clear (dest + startOffsetInDestBuffer, numSamples); + + return false; + } + } + else + { + lock.enter(); + } + + struct ScopedCriticalSectionExit final + { + explicit ScopedCriticalSectionExit (CriticalSection& sectionToUse) noexcept + : section (sectionToUse) + { + } + + ~ScopedCriticalSectionExit() + { + section.exit(); + } + + CriticalSection& section; + }; + + const ScopedCriticalSectionExit lockExit (lock); + nextReadPosition = startSampleInFile;") + +set(JUCE_REALTIME_LOCK_V2 +" if (numSamples <= 0) + return true; + + // OpenStudio uses timeoutMs == 0 from its realtime playback callback. + // A normal ScopedLock can priority-invert against the low-priority + // read-ahead thread. Publish the requested position before attempting the + // lock so the read-ahead thread can chase a realtime miss immediately. + // In realtime mode, report that miss instead of waiting; non-realtime + // callers retain JUCE's original blocking behavior. + nextReadPosition = startSampleInFile; + const bool realtimeTryOnly = timeoutMs == 0; + if (realtimeTryOnly) + { + if (! lock.tryEnter()) + { + for (int channel = 0; channel < numDestChannels; ++channel) + if (auto* dest = reinterpret_cast<float*> (destSamples[channel])) + FloatVectorOperations::clear (dest + startOffsetInDestBuffer, numSamples); + + return false; + } + } + else + { + lock.enter(); + } + + struct ScopedCriticalSectionExit final + { + explicit ScopedCriticalSectionExit (CriticalSection& sectionToUse) noexcept + : section (sectionToUse) + { + } + + ~ScopedCriticalSectionExit() + { + section.exit(); + } + + CriticalSection& section; + }; + + const ScopedCriticalSectionExit lockExit (lock);") + +set(JUCE_REALTIME_LOCK +" if (numSamples <= 0) + return true; + + // OpenStudio uses timeoutMs == 0 from its realtime playback callback. + // A normal ScopedLock can priority-invert against the low-priority + // read-ahead thread. Publish the requested position before attempting the + // lock so the read-ahead thread can chase a realtime miss immediately. + // In realtime mode, report that miss without modifying the destination; + // the caller owns its bounded continuity-concealment policy. Non-realtime + // callers retain JUCE's original blocking behavior. + nextReadPosition = startSampleInFile; + const bool realtimeTryOnly = timeoutMs == 0; + if (realtimeTryOnly) + { + if (! lock.tryEnter()) + return false; + } + else + { + lock.enter(); + } + + struct ScopedCriticalSectionExit final + { + explicit ScopedCriticalSectionExit (CriticalSection& sectionToUse) noexcept + : section (sectionToUse) + { + } + + ~ScopedCriticalSectionExit() + { + section.exit(); + } + + CriticalSection& section; + }; + + const ScopedCriticalSectionExit lockExit (lock);") + +string(FIND + "${JUCE_BUFFERING_READER_SOURCE}" + "${JUCE_REALTIME_LOCK}" + JUCE_PATCHED_AT) +if(JUCE_PATCHED_AT GREATER_EQUAL 0) + message(STATUS + "JUCE realtime BufferingAudioReader patch is already applied") + return() +endif() + +string(FIND + "${JUCE_BUFFERING_READER_SOURCE}" + "${JUCE_REALTIME_LOCK_V2}" + JUCE_PATCH_V2_AT) +if(JUCE_PATCH_V2_AT GREATER_EQUAL 0) + string(REPLACE + "${JUCE_REALTIME_LOCK_V2}" + "${JUCE_REALTIME_LOCK}" + JUCE_BUFFERING_READER_PATCHED_SOURCE + "${JUCE_BUFFERING_READER_SOURCE}") + file(WRITE + "${JUCE_BUFFERING_READER}" + "${JUCE_BUFFERING_READER_PATCHED_SOURCE}") + message(STATUS + "Updated JUCE realtime BufferingAudioReader patch to continuity-safe V3") + return() +endif() + +string(FIND + "${JUCE_BUFFERING_READER_SOURCE}" + "${JUCE_REALTIME_LOCK_V1}" + JUCE_PATCH_V1_AT) +if(JUCE_PATCH_V1_AT GREATER_EQUAL 0) + string(REPLACE + "${JUCE_REALTIME_LOCK_V1}" + "${JUCE_REALTIME_LOCK}" + JUCE_BUFFERING_READER_PATCHED_SOURCE + "${JUCE_BUFFERING_READER_SOURCE}") + file(WRITE + "${JUCE_BUFFERING_READER}" + "${JUCE_BUFFERING_READER_PATCHED_SOURCE}") + message(STATUS + "Updated JUCE realtime BufferingAudioReader patch") + return() +endif() + +string(FIND + "${JUCE_BUFFERING_READER_SOURCE}" + "${JUCE_BLOCKING_LOCK}" + JUCE_UNPATCHED_AT) +if(JUCE_UNPATCHED_AT LESS 0) + message(FATAL_ERROR + "Pinned JUCE BufferingAudioReader patch context changed; refusing an unverified dependency rewrite") +endif() + +string(REPLACE + "${JUCE_BLOCKING_LOCK}" + "${JUCE_REALTIME_LOCK}" + JUCE_BUFFERING_READER_PATCHED_SOURCE + "${JUCE_BUFFERING_READER_SOURCE}") +file(WRITE + "${JUCE_BUFFERING_READER}" + "${JUCE_BUFFERING_READER_PATCHED_SOURCE}") +message(STATUS + "Applied JUCE realtime non-blocking BufferingAudioReader patch") diff --git a/cmake/ApplyNAMCorePatches.cmake b/cmake/ApplyNAMCorePatches.cmake new file mode 100644 index 0000000..3ff8390 --- /dev/null +++ b/cmake/ApplyNAMCorePatches.cmake @@ -0,0 +1,103 @@ +if(NOT DEFINED NAM_SOURCE_DIR) + message(FATAL_ERROR "NAM_SOURCE_DIR was not provided") +endif() + +find_program(NAM_GIT_EXECUTABLE NAMES git) +if(NOT NAM_GIT_EXECUTABLE) + message(FATAL_ERROR "Git is required to patch NeuralAmpModelerCore") +endif() + +function(nam_apply_git_patch PATCH_NAME PATCH_DESCRIPTION) + set(PATCH_PATH "${CMAKE_CURRENT_LIST_DIR}/patches/${PATCH_NAME}") + if(NOT EXISTS "${PATCH_PATH}") + message(FATAL_ERROR "NeuralAmpModelerCore patch was not found: ${PATCH_PATH}") + endif() + + execute_process( + COMMAND "${NAM_GIT_EXECUTABLE}" -C "${NAM_SOURCE_DIR}" + apply --check --whitespace=nowarn "${PATCH_PATH}" + RESULT_VARIABLE PATCH_CHECK_RESULT + OUTPUT_VARIABLE PATCH_CHECK_OUTPUT + ERROR_VARIABLE PATCH_CHECK_ERROR + ) + if(PATCH_CHECK_RESULT EQUAL 0) + execute_process( + COMMAND "${NAM_GIT_EXECUTABLE}" -C "${NAM_SOURCE_DIR}" + apply --whitespace=nowarn "${PATCH_PATH}" + RESULT_VARIABLE PATCH_APPLY_RESULT + OUTPUT_VARIABLE PATCH_APPLY_OUTPUT + ERROR_VARIABLE PATCH_APPLY_ERROR + ) + if(NOT PATCH_APPLY_RESULT EQUAL 0) + message(FATAL_ERROR + "Failed to apply ${PATCH_DESCRIPTION}:\n" + "${PATCH_APPLY_OUTPUT}${PATCH_APPLY_ERROR}") + endif() + message(STATUS "Applied ${PATCH_DESCRIPTION}") + return() + endif() + + execute_process( + COMMAND "${NAM_GIT_EXECUTABLE}" -C "${NAM_SOURCE_DIR}" + apply --reverse --check --whitespace=nowarn "${PATCH_PATH}" + RESULT_VARIABLE PATCH_REVERSE_CHECK_RESULT + OUTPUT_VARIABLE PATCH_REVERSE_CHECK_OUTPUT + ERROR_VARIABLE PATCH_REVERSE_CHECK_ERROR + ) + if(PATCH_REVERSE_CHECK_RESULT EQUAL 0) + message(STATUS "${PATCH_DESCRIPTION} is already applied") + return() + endif() + + message(FATAL_ERROR + "NeuralAmpModelerCore v0.5.4 patch context changed for ${PATCH_DESCRIPTION}; " + "refusing an unverified dependency rewrite.\n" + "Apply check: ${PATCH_CHECK_OUTPUT}${PATCH_CHECK_ERROR}\n" + "Reverse check: ${PATCH_REVERSE_CHECK_OUTPUT}${PATCH_REVERSE_CHECK_ERROR}") +endfunction() + +set(NAM_WAVENET_MODEL "${NAM_SOURCE_DIR}/NAM/wavenet/model.cpp") +if(NOT EXISTS "${NAM_WAVENET_MODEL}") + message(FATAL_ERROR "NeuralAmpModelerCore WaveNet source was not found: ${NAM_WAVENET_MODEL}") +endif() + +file(READ "${NAM_WAVENET_MODEL}" NAM_WAVENET_SOURCE) + +set(NAM_FULL_ACCUMULATOR_CLEAR +" // Zero head inputs accumulator (first layer array) + this->_head_inputs.setZero(); + ProcessInner(layer_inputs, condition, num_frames);") +set(NAM_ACTIVE_ACCUMULATOR_CLEAR +" // Zero head inputs accumulator (first layer array) + this->_head_inputs.leftCols(num_frames).setZero(); + ProcessInner(layer_inputs, condition, num_frames);") + +string(FIND "${NAM_WAVENET_SOURCE}" "${NAM_ACTIVE_ACCUMULATOR_CLEAR}" NAM_PATCHED_AT) +if(NAM_PATCHED_AT GREATER_EQUAL 0) + message(STATUS "NeuralAmpModelerCore realtime small-block patch is already applied") +else() + string(FIND "${NAM_WAVENET_SOURCE}" "${NAM_FULL_ACCUMULATOR_CLEAR}" NAM_UNPATCHED_AT) + if(NAM_UNPATCHED_AT LESS 0) + message(FATAL_ERROR + "NeuralAmpModelerCore v0.5.4 WaveNet patch context changed; " + "refusing an unverified dependency rewrite") + endif() + + string(REPLACE + "${NAM_FULL_ACCUMULATOR_CLEAR}" + "${NAM_ACTIVE_ACCUMULATOR_CLEAR}" + NAM_WAVENET_PATCHED_SOURCE + "${NAM_WAVENET_SOURCE}") + file(WRITE "${NAM_WAVENET_MODEL}" "${NAM_WAVENET_PATCHED_SOURCE}") + message(STATUS "Applied NeuralAmpModelerCore realtime small-block accumulator patch") +endif() + +nam_apply_git_patch( + "NAMCoreSlimmablePendingFastPath.patch" + "NeuralAmpModelerCore SlimmableWavenet lock-free pending fast path") +nam_apply_git_patch( + "NAMCoreConvNetRealtime.patch" + "NeuralAmpModelerCore allocation-free ConvNet callback patch") +nam_apply_git_patch( + "NAMCoreA2FastCurrentBlockMirror.patch" + "NeuralAmpModelerCore A2 fast-path current-block tail mirror") diff --git a/cmake/patches/NAMCoreA2FastCurrentBlockMirror.patch b/cmake/patches/NAMCoreA2FastCurrentBlockMirror.patch new file mode 100644 index 0000000..734f306 --- /dev/null +++ b/cmake/patches/NAMCoreA2FastCurrentBlockMirror.patch @@ -0,0 +1,42 @@ +diff --git a/NAM/wavenet/a2_fast.cpp b/NAM/wavenet/a2_fast.cpp +index 67093c8..8127a7c 100644 +--- a/NAM/wavenet/a2_fast.cpp ++++ b/NAM/wavenet/a2_fast.cpp +@@ -337,7 +337,6 @@ template <int Channels> + void A2FastModel<Channels>::_ring_write(Layer& L, int num_frames) + { + #if NAM_A2_RING_MODE == 1 +- const int mbs = GetMaxBufferSize(); + float* const hist = L.history.data(); + const float* const src = _layer_in.data(); + const int wp = L.write_pos; +@@ -349,7 +348,9 @@ void A2FastModel<Channels>::_ring_write(Layer& L, int num_frames) + static_cast<size_t>(num_frames - first) * Channels * sizeof(float)); + } ++ // A contiguous read can cross the ring end by at most this block's length. + std::memcpy( +- hist + static_cast<size_t>(L.pow2_size) * Channels, hist, static_cast<size_t>(mbs) * Channels * sizeof(float)); ++ hist + static_cast<size_t>(L.pow2_size) * Channels, hist, ++ static_cast<size_t>(num_frames) * Channels * sizeof(float)); + L.write_pos = (wp + num_frames) & L.pow2_mask; + #else + if (L.write_pos + num_frames > L.history_cols) +@@ -369,7 +369,6 @@ template <int Channels> + void A2FastModel<Channels>::_head_ring_write(int num_frames) + { + #if NAM_A2_RING_MODE == 1 +- const int mbs = GetMaxBufferSize(); + float* const hist = _head_history.data(); + const float* const src = _head_sum.data(); + const int wp = _head_write_pos; +@@ -381,7 +380,9 @@ void A2FastModel<Channels>::_head_ring_write(int num_frames) + static_cast<size_t>(num_frames - first) * Channels * sizeof(float)); + } ++ // A contiguous read can cross the ring end by at most this block's length. + std::memcpy( +- hist + static_cast<size_t>(_head_pow2_size) * Channels, hist, static_cast<size_t>(mbs) * Channels * sizeof(float)); ++ hist + static_cast<size_t>(_head_pow2_size) * Channels, hist, ++ static_cast<size_t>(num_frames) * Channels * sizeof(float)); + _head_write_pos = (wp + num_frames) & _head_pow2_mask; + #else + const int keep = kHeadKernelSize - 1; diff --git a/cmake/patches/NAMCoreConvNetRealtime.patch b/cmake/patches/NAMCoreConvNetRealtime.patch new file mode 100644 index 0000000..028dc8f --- /dev/null +++ b/cmake/patches/NAMCoreConvNetRealtime.patch @@ -0,0 +1,227 @@ +diff --git a/NAM/convnet.cpp b/NAM/convnet.cpp +index 329caf6..895e5e3 100644 +--- a/NAM/convnet.cpp ++++ b/NAM/convnet.cpp +@@ -157,20 +157,24 @@ void nam::convnet::_Head::process_(const Eigen::MatrixXf& input, Eigen::MatrixXf + { + const long length = i_end - i_start; + const long out_channels = this->_weight.rows(); ++ const long in_channels = this->_weight.cols(); + +- // Resize output to (out_channels x length) +- output.resize(out_channels, length); +- +- // Extract input slice: (in_channels x length) +- Eigen::MatrixXf input_slice = input.middleCols(i_start, length); +- +- // Compute output = weight * input_slice: (out_channels x in_channels) * (in_channels x length) = (out_channels x +- // length) +- output.noalias() = this->_weight * input_slice; +- +- // Add bias to each column: output.colwise() += bias +- // output is (out_channels x length), bias is (out_channels x 1), so colwise() += works +- output.colwise() += this->_bias; ++ if (length <= 0 || i_start < 0 || i_end > input.cols() || input.rows() != in_channels ++ || output.rows() != out_channels || output.cols() < length) ++ return; ++ ++ // Manual multiply keeps the head allocation-free for every dynamic block size. ++ // Accumulation order is deterministic and matches the scalar reference equation. ++ for (long frame = 0; frame < length; ++frame) ++ { ++ for (long out_ch = 0; out_ch < out_channels; ++out_ch) ++ { ++ float value = this->_bias(out_ch); ++ for (long in_ch = 0; in_ch < in_channels; ++in_ch) ++ value += this->_weight(out_ch, in_ch) * input(in_ch, i_start + frame); ++ output(out_ch, frame) = value; ++ } ++ } + } + + nam::convnet::ConvNet::ConvNet(const int in_channels, const int out_channels, const int channels, +@@ -186,11 +190,6 @@ nam::convnet::ConvNet::ConvNet(const int in_channels, const int out_channels, co + for (size_t i = 0; i < dilations.size(); i++) + this->_blocks[i].set_weights_( + i == 0 ? in_channels : channels, channels, dilations[i], batchnorm, activation_config, groups, it); +- // Only need _block_vals for the head (one entry) +- // Conv1D layers manage their own buffers now +- this->_block_vals.resize(1); +- this->_block_vals[0].setZero(); +- + // Create single head that outputs all channels + this->_head = _Head(channels, out_channels, it); + +@@ -206,75 +205,44 @@ nam::convnet::ConvNet::ConvNet(const int in_channels, const int out_channels, co + void nam::convnet::ConvNet::process(NAM_SAMPLE** input, NAM_SAMPLE** output, const int num_frames) + + { +- this->_update_buffers_(input, num_frames); + const int in_channels = NumInputChannels(); + const int out_channels = NumOutputChannels(); + +- // For multi-channel, we process each input channel independently through the network +- // and sum outputs to each output channel (simple implementation) +- // This can be extended later for more sophisticated cross-channel processing ++ if (num_frames <= 0) ++ return; + +- // Convert input buffers to matrix for first layer (stack input channels) +- Eigen::MatrixXf input_matrix(in_channels, num_frames); +- const long i_start = this->_input_buffer_offset; ++ if (num_frames > GetMaxBufferSize() || _blocks.empty() || _input_matrix.rows() != in_channels ++ || _input_matrix.cols() < num_frames || _head_output.rows() != out_channels ++ || _head_output.cols() < num_frames) ++ { ++ for (int ch = 0; ch < out_channels; ++ch) ++ std::fill_n(output[ch], num_frames, 0.0f); ++ return; ++ } ++ ++ // Conv1D owns the required history. Copy only the current host block into the ++ // pre-allocated matrix instead of updating Buffer's deprecated vector history. + for (int ch = 0; ch < in_channels; ch++) + { + for (int i = 0; i < num_frames; i++) +- input_matrix(ch, i) = this->_input_buffers[ch][i_start + i]; ++ _input_matrix(ch, i) = input[ch][i]; + } + +- // Process through ConvNetBlock layers +- // Each block now uses Conv1D's internal buffers via Process() and GetOutput() + for (size_t i = 0; i < this->_blocks.size(); i++) + { +- // Get input for this block +- Eigen::MatrixXf block_input; + if (i == 0) +- { +- // First block uses the input matrix +- block_input = input_matrix; +- } ++ this->_blocks[i].Process(_input_matrix, num_frames); + else +- { +- // Subsequent blocks use output from previous block +- auto prev_output = this->_blocks[i - 1].GetOutput(num_frames); +- block_input = prev_output; // Copy to matrix +- } +- +- // Process block (handles Conv1D, batchnorm, and activation internally) +- this->_blocks[i].Process(block_input, num_frames); +- } +- +- // Process head for all output channels at once +- // We need _block_vals[0] for the head interface +- const long buffer_size = (long)this->_input_buffers[0].size(); +- if (this->_block_vals[0].rows() != this->_blocks.back().get_out_channels() +- || this->_block_vals[0].cols() != buffer_size) +- { +- this->_block_vals[0].resize(this->_blocks.back().get_out_channels(), buffer_size); ++ this->_blocks[i].Process(this->_blocks[i - 1].GetOutput(), num_frames); + } + +- // Copy last block output to _block_vals for head +- auto last_output = this->_blocks.back().GetOutput(num_frames); +- const long buffer_offset = this->_input_buffer_offset; +- const long buffer_i_end = buffer_offset + num_frames; +- // last_output is (channels x num_frames), _block_vals[0] is (channels x buffer_size) +- // Copy to the correct location in _block_vals +- this->_block_vals[0].block(0, buffer_offset, last_output.rows(), num_frames) = last_output; +- +- // Process head - outputs all channels at once +- // Head will resize _head_output internally +- this->_head.process_(this->_block_vals[0], this->_head_output, buffer_offset, buffer_i_end); ++ this->_head.process_(this->_blocks.back().GetOutput(), this->_head_output, 0, num_frames); + +- // Copy to output arrays for each channel + for (int ch = 0; ch < out_channels; ch++) + { + for (int s = 0; s < num_frames; s++) + output[ch][s] = this->_head_output(ch, s); + } +- +- // Prepare for next call: +- nam::Buffer::_advance_input_buffer_(num_frames); + } + + void nam::convnet::ConvNet::_verify_weights(const int channels, const std::vector<int>& dilations, const bool batchnorm, +@@ -285,41 +253,16 @@ void nam::convnet::ConvNet::_verify_weights(const int channels, const std::vecto + + void nam::convnet::ConvNet::SetMaxBufferSize(const int maxBufferSize) + { +- nam::Buffer::SetMaxBufferSize(maxBufferSize); ++ nam::DSP::SetMaxBufferSize(maxBufferSize); ++ const int prepared_size = std::max(0, maxBufferSize); + +- // Reset all ConvNetBlock instances with the new buffer size +- for (auto& block : _blocks) +- { +- block.SetMaxBufferSize(maxBufferSize); +- } +-} +- +-void nam::convnet::ConvNet::_update_buffers_(NAM_SAMPLE** input, const int num_frames) +-{ +- this->Buffer::_update_buffers_(input, num_frames); ++ _input_matrix.resize(NumInputChannels(), prepared_size); ++ _input_matrix.setZero(); ++ _head_output.resize(NumOutputChannels(), prepared_size); ++ _head_output.setZero(); + +- // All channels use the same buffer size +- const long buffer_size = (long)this->_input_buffers[0].size(); +- +- // Only need _block_vals[0] for the head +- // Conv1D layers manage their own buffers now +- if (this->_block_vals[0].rows() != this->_blocks.back().get_out_channels() +- || this->_block_vals[0].cols() != buffer_size) +- { +- this->_block_vals[0].resize(this->_blocks.back().get_out_channels(), buffer_size); +- this->_block_vals[0].setZero(); +- } +-} +- +-void nam::convnet::ConvNet::_rewind_buffers_() +-{ +- // Conv1D instances now manage their own ring buffers and handle rewinding internally +- // So we don't need to rewind _block_vals for Conv1D layers +- // We only need _block_vals for the head, and it doesn't need rewinding since it's only used +- // for the current frame range +- +- // Just rewind the input buffer (for Buffer base class) +- this->Buffer::_rewind_buffers_(); ++ for (auto& block : _blocks) ++ block.SetMaxBufferSize(prepared_size); + } + + // Config parser +diff --git a/NAM/convnet.h b/NAM/convnet.h +index 394389e..a34fe23 100644 +--- a/NAM/convnet.h ++++ b/NAM/convnet.h +@@ -92,6 +92,10 @@ public: + /// \return Block reference to the output + Eigen::Block<Eigen::MatrixXf> GetOutput(const int num_frames); + ++ /// \brief Get the full pre-allocated output buffer ++ /// \return Const reference; only the first num_frames columns are valid ++ const Eigen::MatrixXf& GetOutput() const { return _output; } ++ + /// \brief Get the number of output channels + /// \return Number of output channels + long get_out_channels() const; +@@ -155,13 +159,11 @@ public: + + protected: + std::vector<ConvNetBlock> _blocks; +- std::vector<Eigen::MatrixXf> _block_vals; ++ Eigen::MatrixXf _input_matrix; // (in_channels, prepared maximum frames) + Eigen::MatrixXf _head_output; // (out_channels, num_frames) + _Head _head; + void _verify_weights(const int channels, const std::vector<int>& dilations, const bool batchnorm, + const size_t actual_weights); +- void _update_buffers_(NAM_SAMPLE** input, const int num_frames) override; +- void _rewind_buffers_() override; + + int mPrewarmSamples = 0; // Pre-compute during initialization + }; diff --git a/cmake/patches/NAMCoreSlimmablePendingFastPath.patch b/cmake/patches/NAMCoreSlimmablePendingFastPath.patch new file mode 100644 index 0000000..f7691f7 --- /dev/null +++ b/cmake/patches/NAMCoreSlimmablePendingFastPath.patch @@ -0,0 +1,115 @@ +diff --git a/NAM/wavenet/slimmable.cpp b/NAM/wavenet/slimmable.cpp +index 7112105..fe38781 100644 +--- a/NAM/wavenet/slimmable.cpp ++++ b/NAM/wavenet/slimmable.cpp +@@ -306,44 +306,59 @@ bool is_full_size(const std::vector<wavenet::LayerArrayParams>& params, const st + + } // anonymous namespace + ++static_assert(std::atomic<bool>::is_always_lock_free, ++ "SlimmableWavenet requires a lock-free pending-model publication hint"); ++ + #if NAM_HAS_ATOMIC_SHARED_PTR + void SlimmableWavenet::_pending_clear_release() + { ++ _pending_available.store(false, std::memory_order_release); + _pending_staged.store({}, std::memory_order_release); + } + + std::shared_ptr<SlimmableWavenet::StagedSlimModel> SlimmableWavenet::_pending_load_acquire() const + { ++ if (!_pending_available.load(std::memory_order_acquire)) ++ return {}; + return _pending_staged.load(std::memory_order_acquire); + } + + void SlimmableWavenet::_pending_store_release(std::shared_ptr<StagedSlimModel> p) + { + _pending_staged.store(std::move(p), std::memory_order_release); ++ _pending_available.store(true, std::memory_order_release); + } + + std::shared_ptr<SlimmableWavenet::StagedSlimModel> SlimmableWavenet::_pending_exchange_take_acq_rel() + { ++ if (!_pending_available.exchange(false, std::memory_order_acq_rel)) ++ return {}; + return _pending_staged.exchange({}, std::memory_order_acq_rel); + } + #else + void SlimmableWavenet::_pending_clear_release() + { ++ _pending_available.store(false, std::memory_order_release); + std::atomic_store_explicit(&_pending_staged, std::shared_ptr<StagedSlimModel>{}, std::memory_order_release); + } + + std::shared_ptr<SlimmableWavenet::StagedSlimModel> SlimmableWavenet::_pending_load_acquire() const + { ++ if (!_pending_available.load(std::memory_order_acquire)) ++ return {}; + return std::atomic_load_explicit(&_pending_staged, std::memory_order_acquire); + } + + void SlimmableWavenet::_pending_store_release(std::shared_ptr<StagedSlimModel> p) + { + std::atomic_store_explicit(&_pending_staged, std::move(p), std::memory_order_release); ++ _pending_available.store(true, std::memory_order_release); + } + + std::shared_ptr<SlimmableWavenet::StagedSlimModel> SlimmableWavenet::_pending_exchange_take_acq_rel() + { ++ if (!_pending_available.exchange(false, std::memory_order_acq_rel)) ++ return {}; + return std::atomic_exchange_explicit(&_pending_staged, std::shared_ptr<StagedSlimModel>{}, std::memory_order_acq_rel); + } + #endif +@@ -488,10 +503,15 @@ void SlimmableWavenet::_stage_rebuild_model(const std::vector<int>& target_chann + + void SlimmableWavenet::process(NAM_SAMPLE** input, NAM_SAMPLE** output, const int num_frames) + { +- if (auto pack = _pending_exchange_take_acq_rel()) ++ // MSVC implements atomic<shared_ptr> with an internal lock. Keep that operation ++ // entirely off the steady-state audio path; it runs only when a staged model exists. ++ if (_pending_available.load(std::memory_order_acquire)) + { +- _active_model = std::move(pack->model); +- _current_channels = std::move(pack->channels); ++ if (auto pack = _pending_exchange_take_acq_rel()) ++ { ++ _active_model = std::move(pack->model); ++ _current_channels = std::move(pack->channels); ++ } + } + if (_active_model) + _active_model->process(input, output, num_frames); +diff --git a/NAM/wavenet/slimmable.h b/NAM/wavenet/slimmable.h +index 12c0268..740e83e 100644 +--- a/NAM/wavenet/slimmable.h ++++ b/NAM/wavenet/slimmable.h +@@ -1,5 +1,6 @@ + #pragma once + ++#include <atomic> + #include <memory> + #include <vector> + +@@ -15,10 +16,6 @@ + #define NAM_HAS_ATOMIC_SHARED_PTR 0 + #endif + +-#if NAM_HAS_ATOMIC_SHARED_PTR +- #include <atomic> +-#endif +- + #include "../dsp.h" + #include "json.hpp" + #include "../model_config.h" +@@ -88,6 +85,9 @@ private: + /// Staged model; synchronized via deprecated std::atomic_* overloads for shared_ptr only. + std::shared_ptr<StagedSlimModel> _pending_staged; + #endif ++ /// Lock-free publication hint. The audio thread reads this first so steady-state ++ /// processing never enters the potentially locked atomic<shared_ptr> implementation. ++ std::atomic<bool> _pending_available{false}; + + std::vector<int> _current_channels; + int _current_buffer_size = 0; diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md deleted file mode 100644 index e0c1345..0000000 --- a/docs/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,429 +0,0 @@ -# Studio13-v3 Implementation Plan - -> **Goal**: Close the gap between Studio13-v3 and professional DAWs like Ardour/REAPER by implementing missing features end-to-end (C++ backend + bridge + React frontend). - ---- - -## Remaining Work — Honest Audit (March 2026) - -Everything below was audited against the actual codebase. Features are categorized by what's actually missing. - -### Category A: Broken — Code Exists Both Sides But Not Connected - -| # | Feature | Problem | Fix Effort | -|---|---------|---------|------------| -| 8 | Clip Gain Envelope | Backend interpolation works. Frontend state+undo works. But `syncClipsWithBackend()` never sends `gainEnvelope` to backend. Also no UI to draw envelope points on clips. | Medium | -| 13 | Trigger Engine (Clip Launcher) | Backend `TriggerEngine::processBlock()` runs in audio callback. Frontend store has full state. But NO bridge functions sync slot data from frontend to backend. Clips never actually launch. | Medium | - -### Category B: Backend Done, No Frontend UI - -| # | Feature | What Exists | What's Missing | -|---|---------|-------------|----------------| -| 4 | Channel Strip EQ | S13EQ class (HPF+LPF+4 bands), processes before FX chain in TrackProcessor | No UI controls — no enable toggle, no frequency/gain/Q knobs | -| 5 | Sidechain Routing | Topological sort, sidechain buffer routing, TrackProcessor expands channels | No UI to select sidechain source per plugin | -| 6 | Pan Law Options | All 4 algorithms (Constant Power, -4.5dB, -6dB, Linear) in TrackProcessor | No dropdown UI in Project Settings | -| 7 | DC Offset Removal | 5Hz high-pass filter in TrackProcessor | No toggle UI per track | -| 10 | Monitoring FX Chain | Post-master chain, correctly excluded from renders | No UI to add/manage monitoring FX | -| 12 | Timecode Sync | Full MIDI Clock + MTC send/receive in TimecodeSync.h/cpp | No settings panel for sync source/MIDI device/framerate | - -### Category C: Not Implemented At All - -| # | Feature | Notes | -|---|---------|-------| -| 29 | Crosshair Cursor | Only exists in ParametricGraph (EQ editing), NOT as timeline toggle | -| 30 | Accessibility | No ARIA labels, no focus indicators, no systematic keyboard navigation | -| — | Missing Media Resolver | No detection of missing audio files on project load, no relink dialog | -| — | Tab-to-Transient Navigation | No Tab/Shift+Tab handler for jumping between transients | -| — | Contextual Help (F1) | helpTexts.ts exists but no F1 key handler or hover help system | -| — | In-App Getting Started Guide | No tutorial/onboarding component | -| — | User Manual / API Docs | No manual or doc generation | -| — | Plugin Crash Isolation | Only blacklist exists, no separate-process sandboxing | -| — | 32-bit Plugin Bridge | Not implemented | - -### Category D: Uncertain / Incomplete - -| Feature | Status | -|---------|--------| -| Lagrange Resampling in Render | Referenced in code comments but implementation not fully visible | -| Note Expression / MPE | Infrastructure exists (setNoteExpression action, pitchBend/pressure/slide) but Piano Roll UI for MPE editing may be incomplete | - ---- - -## Detailed Plans for Remaining Work - -### Plan R1: Fix Clip Gain Envelope (Feature #8) - -**Priority**: High — backend code is wasted without the sync - -**Step 1: Add gainEnvelope to syncClipsWithBackend()** -- File: `frontend/src/store/useDAWStore.ts` -- In `syncClipsWithBackend()` (~line 4278-4350), after syncing basic clip properties, add: - ```typescript - // After addPlaybackClip calls, sync gain envelopes - for (const track of state.tracks) { - for (const clip of track.clips) { - if (clip.gainEnvelope && clip.gainEnvelope.length > 0) { - await nativeBridge.setClipGainEnvelope(track.id, clip.id, clip.gainEnvelope); - } - } - } - ``` -- Also add gainEnvelope to the clip key hash so changes trigger re-sync - -**Step 2: Add clip gain drawing UI** -- File: `frontend/src/components/Timeline.tsx` -- When a clip is selected and Shift is held, show gain envelope overlay: - - Render gain points as small circles on the clip - - Click to add point, drag to move, right-click to delete - - Points are relative to clip start, value 0.0-2.0 (0 = silence, 1.0 = unity, 2.0 = +6dB) -- Wire to existing `addClipGainPoint()`, `moveClipGainPoint()`, `removeClipGainPoint()` in store - -**Undo**: Already implemented in store (addClipGainPoint etc. use commandManager) - ---- - -### Plan R2: Fix Trigger Engine Bridge (Feature #13) - -**Priority**: High — complete audio engine wasted without bridge - -**Step 1: Add bridge functions in MainComponent.cpp** -- Register native functions: - - `setTriggerSlot(trackIndex, slotIndex, filePath, duration, offset, mode)` → calls `triggerEngine.setSlotClip()` - - `triggerSlot(trackIndex, slotIndex)` → calls `triggerEngine.triggerSlot()` - - `stopSlot(trackIndex, slotIndex)` → calls `triggerEngine.stopSlot()` - - `triggerScene(sceneIndex)` → calls `triggerEngine.triggerScene()` - - `stopAllSlots()` → calls `triggerEngine.stopAll()` - - `setTriggerQuantize(mode)` → calls `triggerEngine.setQuantizeMode()` - - `getTriggerGridState()` → calls `triggerEngine.getGridState()` - -**Step 2: Add NativeBridge.ts wrappers** -- Add corresponding functions with mock fallbacks - -**Step 3: Wire frontend store to bridge** -- In `triggerClipLauncherSlot()`, `stopClipLauncherSlot()`, etc. — add `nativeBridge.triggerSlot()` calls -- On play, sync all slot assignments to backend via `setTriggerSlot()` for each populated slot - -**Step 4: Add Clip Launcher UI** -- New component `ClipLauncherView.tsx` — grid of slots, each with play/stop/record buttons -- Toggle between Arrangement view and Session view -- Scene launch row at bottom - ---- - -### Plan R3: Channel Strip EQ UI (Feature #4) - -**Priority**: Medium — enhances mixing workflow - -**Where**: `frontend/src/components/ChannelStrip.tsx` - -**Implementation**: -- Add collapsible "EQ" section in ChannelStrip (below existing Gain Staging section) -- Enable/disable toggle → calls `nativeBridge.setChannelStripEQEnabled(trackId, bool)` -- Per-band controls (HPF, LPF, 4 parametric): - - Frequency knob/slider - - Gain knob/slider (parametric bands only) - - Q knob/slider (parametric bands only) - - Enable toggle per band -- Wire each control to `nativeBridge.setChannelStripEQParam(trackId, bandIndex, paramName, value)` -- Optional: Reuse ParametricGraph component for visual EQ curve - -**Bridge functions needed**: Already exposed (`setChannelStripEQEnabled`, `setChannelStripEQParam`) - ---- - -### Plan R4: Sidechain Routing UI (Feature #5) - -**Priority**: Medium — essential for sidechain compression workflows - -**Where**: `frontend/src/components/FXChainPanel.tsx` - -**Implementation**: -- Per plugin slot, add a "Sidechain" dropdown (only for plugins that support sidechain input) -- Dropdown lists all other tracks as potential sidechain sources -- On select → `nativeBridge.setSidechainSource(trackId, fxIndex, sourceTrackId)` -- "None" option → `nativeBridge.clearSidechainSource(trackId, fxIndex)` -- Visual indicator when sidechain is active (small icon/badge on plugin slot) - -**Bridge functions needed**: Already exposed (`setSidechainSource`, `clearSidechainSource`, `getSidechainSource`) - ---- - -### Plan R5: Pan Law Options UI (Feature #6) - -**Priority**: Low — rarely changed, but easy to add - -**Where**: `frontend/src/components/ProjectSettingsModal.tsx` - -**Implementation**: -- Add "Pan Law" dropdown in project settings, options: - - Constant Power (-3dB) — default - - -4.5dB - - -6dB (Linear) - - 0dB (Unity) -- On change → `nativeBridge.setPanLaw(value)` -- Store current pan law in project state for save/load - -**Bridge functions needed**: Already exposed (`setPanLaw`) - ---- - -### Plan R6: DC Offset Removal UI (Feature #7) - -**Priority**: Low — niche feature, easy to add - -**Where**: `frontend/src/components/ChannelStrip.tsx` or track context menu - -**Implementation**: -- Add "DC Offset" toggle per track (small checkbox or button) -- On toggle → `nativeBridge.setTrackDCOffset(trackId, enabled)` -- Visual indicator when active - -**Bridge functions needed**: Already exposed (`setTrackDCOffset`) - ---- - -### Plan R7: Monitoring FX Chain UI (Feature #10) - -**Priority**: Medium — important for recording musicians - -**Where**: New section in `MixerPanel.tsx` or dedicated panel - -**Implementation**: -- Add "Monitor FX" section after Master channel strip -- Add/remove plugins → `nativeBridge.addMonitoringFX(pluginId)`, `removeMonitoringFX(index)` -- Bypass toggle → `nativeBridge.bypassMonitoringFX(index, bypassed)` -- Open plugin editor → `nativeBridge.openMonitoringFXEditor(index)` -- Clear visual label: "Monitor Only — not included in renders" - -**Bridge functions needed**: Already exposed - ---- - -### Plan R8: Timecode Sync Settings UI (Feature #12) - -**Priority**: Low — needed for professional studio integration - -**Where**: New component `TimecodeSettingsPanel.tsx` or tab in SettingsModal - -**Implementation**: -- Sync Source dropdown: Internal / MIDI Clock / MTC -- MIDI Output Device selector (for clock/MTC send) -- MIDI Input Device selector (for external sync) -- SMPTE Frame Rate: 24 / 25 / 29.97df / 30 -- Sync status indicator (locked/unlocked/seeking) -- Wire to TimecodeSyncManager via bridge calls: - - `setSyncSource(mode)` - - `setTimecodeFrameRate(fps)` - - `setTimecodeMIDIDevice(deviceId, isInput)` - -**Bridge functions needed**: Need to be added to MainComponent.cpp for TimecodeSyncManager - ---- - -### Plan R9: Crosshair Cursor (Feature #29) - -**Priority**: Low — visual aid - -**Where**: `frontend/src/components/Timeline.tsx` - -**Implementation**: -- Add `showCrosshair: boolean` to store, toggle via View menu -- On mouse move over timeline stage, render: - - Vertical line from top to bottom at mouse X - - Horizontal line across full width at mouse Y - - Semi-transparent, dashed style -- Use a dedicated Konva Layer (non-listening) for performance -- Hide during drag operations - ---- - -### Plan R10: Accessibility (Feature #30) - -**Priority**: Medium-High — important for inclusivity, significant effort - -**Implementation** (phased): - -**Phase A: ARIA labels + focus indicators** -- Add `aria-label` to all buttons, inputs, sliders across all components -- Add `tabIndex` to interactive elements -- Add visible focus rings (already in Tailwind: `focus:ring-2 focus:ring-daw-accent`) -- Ensure all tooltips include shortcut hints - -**Phase B: Keyboard navigation** -- Track list: Arrow up/down to select tracks, Enter to expand -- Timeline: Arrow keys to move selection, Shift+Arrow to extend -- Mixer: Tab between channel strips, arrow keys for faders -- All modals: Tab trap, Escape to close - -**Phase C: Screen reader optimization** -- Announce state changes (recording started, track armed, etc.) -- Live regions for transport status, meter readings -- Landmark roles for main areas (timeline, mixer, transport) - ---- - -### Plan R11: Missing Media Resolver - -**Priority**: Medium — prevents broken projects - -**Where**: `frontend/src/store/useDAWStore.ts` (in project load) + new dialog component - -**Implementation**: -- On project load, after parsing clips, check if each audio file exists via `nativeBridge.fileExists(path)` -- If any files are missing, show `MissingMediaDialog.tsx`: - - List of missing files with original paths - - "Locate" button per file → opens file picker - - "Locate Folder" → search a directory for matching filenames - - "Skip" → keep clip but mark as offline (dimmed in timeline) -- Update clip paths after relinking - ---- - -### Plan R12: Tab-to-Transient Navigation - -**Priority**: Low — workflow enhancement - -**Where**: `frontend/src/components/App.tsx` (keyboard handler) + `Timeline.tsx` - -**Implementation**: -- Tab key: Jump playhead to next transient in selected clip -- Shift+Tab: Jump to previous transient -- Requires transient detection: call `nativeBridge.detectTransients(filePath, sensitivity)` (may need C++ implementation if not already present) -- Cache transient positions per clip -- Move playhead to nearest transient after current position - ---- - -## Completed Features Reference - -<details> -<summary>Phase 1: Wire Frontend to Backend (ALL COMPLETED)</summary> - -- 1.1 Automation Playback — Per-sample volume/pan automation in TrackProcessor, frontend sync, touch/latch modes -- 1.2 Tempo Map — Binary search tempo lookup, metronome integration, tempo markers -- 1.3 Comping / Takes — Frontend take management synced to backend via clip swap -- 1.4 Razor Editing — Backend clip sync after razor content deletion -- 1.5 Track Groups — Linked parameter changes propagated to backend per-track -</details> - -<details> -<summary>Phase 2: Complete Stubs (ALL COMPLETED)</summary> - -- 2.1 MIDI Recording — MIDIRecorder captures events during recording, saves to .mid -- 2.2 Time Stretching — RubberBand/FFmpeg integration for offline stretch -- 2.3 Pitch Shifting — Same engine as time stretching -- 2.4 Sample Rate Conversion on Render — Device rate conversion in render path -- 2.5 Dither — TPDF and noise-shaped dither on 16/24-bit export -- 2.6 Monitoring FX Chain — Backend complete (see Plan R7 for missing UI) -</details> - -<details> -<summary>Phase 3: New Features (ALL COMPLETED except UI gaps)</summary> - -- 3.1 Punch In/Out Recording — Punch range in audio callback -- 3.2 Loop Recording — Multi-take per loop pass -- 3.3 Record-Safe Mode — Per-track lock on arming -- 3.4 LV2 Plugin Support — JUCE built-in + configured search paths -- 3.5 CLAP Plugin Support — CLAPPluginFormat class -- 3.6 Audio Units — Deferred (macOS only) -- 3.7 Surround / Spatial Audio — VBAP panner, speaker layouts -- 3.8 Video Integration — VideoReader + FFmpeg + VideoWindow UI -- 3.9 Timecode / Sync — Backend complete (see Plan R8 for missing UI) -- 3.10 Control Surfaces — Generic MIDI, MCU, OSC implementations -- 3.11 Scripting Engine — Lua s13.* API expanded -- 3.12 Strip Silence — Detection + split -- 3.13 Freeze Track — Render + bypass FX -- 3.14 Session Import/Export — RPP import/export + EDL export -- 3.15 DDP Export — Full DDP 2.0 exporter + UI -</details> - -<details> -<summary>Phase 4: Advanced Features (COMPLETED except bridge gaps)</summary> - -- 4.1 Clip Launch / Trigger Engine — Backend complete (see Plan R2 for broken bridge) -- 4.2 Step Sequencer — Drum Editor implementation -- 4.3 Built-in Effects — S13 EQ/Comp/Gate/Limiter/Delay/Reverb/Chorus/Saturator -- 4.4 Sidechain — Backend complete (see Plan R4 for missing UI) -</details> - -<details> -<summary>Deferred Features — Status</summary> - -**Visual / Cosmetic**: All done (waveform rendering, clip rendering, fade curves, automation styling, themes, track icons, meters, piano roll, transport, scrollbars, loading states, animations, High-DPI, color picker) - -**Interaction / Workflow**: Mostly done. Missing: crosshair cursor (Plan R9), tab-to-transient (Plan R12), contextual help F1. Done: drag-and-drop, custom shortcuts, smart tool, snap preview, track folders, slip editing, marquee zoom, auto-scroll, media browser, project notes, track notes, waveform zoom, spectral view, time selection, recent files. - -**Performance / Optimization**: All done (diff-based sync, waveform virtualization, useShallow everywhere, LRU eviction, concurrent peaks, Konva layers, plugin scan caching, bridge batching, reader pooling). - -**Audio Quality**: Done except Lagrange resampling (uncertain). Done: pan law options (backend), DC offset removal (backend), gain staging display, oversampling, LUFS/phase/spectrum metering. - -**MIDI Editing**: All done (velocity, CC lanes, quantize, transform, step input, multi-clip, MIDI learn, drum editor, MIDI import/export, scale highlighting). Note expression/MPE infrastructure exists but UI may be incomplete. - -**Plugin Management**: Mostly done. Missing: crash isolation (sandboxing), 32-bit bridge. Done: favorites, categories, presets, generic editor, A/B comparison, chain presets, PDC, parameter automation list. - -**Project Management**: All done (templates, archive, missing media — actually missing media resolver NOT done, media pool, cleanup, auto-save, compare, metadata). - -**Mixing / Routing**: Mostly done. Missing: channel strip EQ UI (Plan R3). Done: routing matrix, bus workflow, pre/post fader sends, mixer snapshots, mixer undo, VCA faders. - -**Accessibility**: NOT DONE — see Plan R10. - -**Documentation / Help**: Mostly NOT DONE. Missing: in-app guide, F1 help, user manual, API docs. Done: keyboard shortcut cheat sheet (printable). -</details> - ---- - -## Implementation Priority Order - -### Sprint A (Fix Broken Features) — HIGH PRIORITY -1. **R1: Clip Gain Envelope sync** — Add gainEnvelope to syncClipsWithBackend + clip gain drawing UI -2. **R2: Trigger Engine bridge** — Add bridge functions + wire frontend store + basic Clip Launcher UI - -### Sprint B (Add Missing UI for Backend Features) — MEDIUM PRIORITY -3. **R3: Channel Strip EQ UI** — Collapsible EQ section in ChannelStrip -4. **R4: Sidechain Routing UI** — Per-plugin sidechain source dropdown in FXChainPanel -5. **R5: Pan Law dropdown** — Simple dropdown in ProjectSettingsModal -6. **R6: DC Offset toggle** — Per-track toggle -7. **R7: Monitoring FX UI** — Monitor FX section in mixer - -### Sprint C (Missing Features) — MEDIUM PRIORITY -8. **R9: Crosshair Cursor** — Konva layer with vertical+horizontal lines -9. **R11: Missing Media Resolver** — Detection on project load + relink dialog - -### Sprint D (Accessibility) — MEDIUM-HIGH PRIORITY -10. **R10: Accessibility Phase A** — ARIA labels + focus indicators across all components -11. **R10: Accessibility Phase B** — Keyboard navigation -12. **R10: Accessibility Phase C** — Screen reader optimization - -### Sprint E (Low Priority Remaining) -13. **R8: Timecode Sync UI** — Sync settings panel -14. **R12: Tab-to-Transient** — Transient detection + keyboard navigation -15. Contextual help (F1) system -16. In-app getting started guide -17. User manual / API documentation -18. Plugin crash isolation (separate process sandboxing) -19. 32-bit plugin bridge -20. MPE/Note Expression UI completion - ---- - -## Score Card - -| Category | Done | Remaining | Total | -|----------|------|-----------|-------| -| Core Audio Features (1-13) | 7 | 6 (UI gaps + 2 broken) | 13 | -| Frontend Features (14-30) | 15 | 2 | 17 | -| Backend Features (31-36) | 6 | 0 | 6 | -| Major Flow Changes | 7 | 1 uncertain | 8 | -| Deferred: Visual/Cosmetic | 17 | 0 | 17 | -| Deferred: Interaction/Workflow | 17 | 3 | 20 | -| Deferred: Performance | 14 | 0 | 14 | -| Deferred: Audio Quality | 8 | 1 uncertain | 9 | -| Deferred: MIDI Editing | 11 | 0-1 (MPE) | 11 | -| Deferred: Plugin Management | 8 | 2 | 10 | -| Deferred: Project Management | 7 | 1 | 8 | -| Deferred: Mixing/Routing | 6 | 1 | 7 | -| Deferred: Accessibility | 0 | 6 | 6 | -| Deferred: Documentation | 1 | 4 | 5 | -| **TOTAL** | **~124** | **~27** | **~151** | - -**Completion: ~82%** — Most core functionality works. Remaining work is primarily UI wiring, accessibility, and documentation. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e796ef2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# OpenStudio Documentation + +OpenStudio keeps documentation task-oriented and close to the code. The goal is +the same pattern used by mature open-source libraries: a small navigation page, +stable guides by subject, executable examples, and one short roadmap instead of +date-stamped implementation diaries. + +## Start here + +- [User manual](USER_MANUAL.md) — install, configure, record, edit, mix, and export. +- [Implemented features](implemented_features.md) — current feature inventory and caveats. +- [NAM Rack](nam-rack.md) — Guitar/Bass capture workflow, multi-capture selection, DSP/state contract, TONE3000 integration, and release acceptance. +- [Keyboard and mouse profiles](input-profiles.md) — built-in DAW profiles, independent keyboard/mouse selection, scoped bindings, and custom profile import/export. +- [MIDI editor](midi-editor.md) — supported editing contract and manual acceptance. +- [NAM and audio QA](testing.md) — deterministic checks and manual release acceptance. +- [Release roadmap](roadmap.md) — only work that is still open or deliberately deferred. +- [Release runbook](release-runbook.md) — packaging and publication. +- [Release smoke checklist](release-smoke-checklist.md) — final build acceptance. +- [Runtime dependency contract](runtime-dependency-contract.md) — optional runtimes and models. +- [Lua API](API.md) — scripting reference. + +## Documentation rules + +1. Document the current product, not the history of how it was reached. +2. Put user workflows before implementation detail. +3. Keep runnable commands beside the behavior they verify. +4. Mark audio evidence as `pass`, `fail`, `diagnostic_only`, or + `not_asserted`. +5. Never claim subjective tone, naturalness, or commercial-product parity from + automated metrics. +6. Keep dated investigations in Git history or an issue tracker after their + decisions have been folded into a stable guide. +7. Prefer links to authoritative source files over copied code. +8. Update the guide and test in the same change when a public contract changes. + +Detailed research that remains useful for pitch rendering is consolidated in +`pitch_renderer_research_notes.md`. It is technical reference material, not an +active release plan. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index c084149..f5a6795 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -27,6 +27,12 @@ Version 3.0 -- Comprehensive Reference Guide --- +> **Shortcut notation:** Inline shortcuts in this manual show the OpenStudio +> default keyboard and mouse profiles. The active profile, operating system, +> editor scope, and custom overrides can change them. **Help > Keyboard +> Shortcuts** is authoritative for keys and selected base profiles; the +> fine-grained mouse-gesture overrides are shown in **Preferences > Mouse**. + ## 1. Getting Started ### 1.1 System Requirements @@ -35,7 +41,8 @@ OpenStudio is a desktop DAW built with a JUCE C++ audio backend and a React/Type **Minimum requirements:** -- Windows 10 or later (64-bit), or macOS for the desktop release +- Windows 10 or later (64-bit), a supported macOS release, or an x86-64 Linux + desktop capable of running AppImage packages - WebView2 Runtime on Windows (typically pre-installed on Windows 10/11) - Audio interface with ASIO, WASAPI, or DirectSound drivers on Windows - 4 GB RAM (8 GB or more recommended) @@ -53,7 +60,7 @@ OpenStudio is a desktop DAW built with a JUCE C++ audio backend and a React/Type OpenStudio production releases are distributed as platform-specific install packages. -1. Download the latest Windows installer or macOS package from the official download page. +1. Download the latest Windows installer, macOS package, or Linux AppImage from the official download page. 2. Run the installer and complete the setup steps for your platform. 3. Launch OpenStudio from the Start menu, Applications folder, or desktop shortcut. @@ -61,6 +68,10 @@ OpenStudio production releases are distributed as platform-specific install pack **macOS:** OpenStudio v1 ships as an unsigned DMG. Drag `OpenStudio.app` to `Applications`. If macOS blocks launch, right-click the app, choose **Open**, and if needed allow it under **System Settings > Privacy & Security**. +**Linux:** mark the downloaded AppImage executable and launch it from your +desktop or terminal. FFmpeg-backed operations use an optional system `ffmpeg` +on `PATH`; the AppImage does not bundle an arbitrary host FFmpeg binary. + OpenStudio also includes automatic update support. You can trigger a manual update check from **Help > Check for Updates...**. Stem separation uses optional AI Tools that are installed separately from the base app. If AI Tools are missing, use the **AI Tools** button beside the Settings button or the **Install AI Tools** button inside the Stem Separation dialog. @@ -220,20 +231,20 @@ The Timeline is the central canvas-based workspace where you arrange audio and M - Trim handles on the left and right edges (in Select or Smart tool mode) - **Grid lines**: Vertical lines aligned to the current grid setting (bar, beat, subdivision). - **Automation lanes**: Per-track lanes below the track that display automation curves. -- **Time selection**: A highlighted region created by clicking and dragging on the ruler or timeline background. -- **Razor edits**: Semi-transparent selection areas created with Alt+drag for precise non-destructive editing. +- **Time selection**: A highlighted region created with Primary+drag on the timeline background or Shift+drag on the ruler (`Primary` is Ctrl on Windows/Linux and Command on macOS). +- **Razor edits**: Semi-transparent selection areas created with Alt/Option+drag on the timeline background for precise non-destructive editing. **Essential Navigation:** - **Scroll**: native vertical scrolling through the workspace. - **Ctrl+Scroll**: horizontal timeline zoom around the mouse pointer. - **Shift+Scroll**: horizontal timeline scroll. - **Alt+Scroll**: track height resize. -- **Ctrl+Shift+Scroll**: track-height zoom style adjustment for faster resizing. +- **Ctrl+Shift+Scroll**: waveform-height zoom for the hovered track. - **First-session hotkeys**: `Space`, `Ctrl+R`, `Ctrl+T`, `Ctrl+M`, `S`, `B`, `Delete`, `Ctrl+S`, `F1`, `Ctrl+Shift+P`. -- **Need a refresher?** Press `F1` for the searchable **Help Reference** and open **Help > Keyboard Shortcuts** for the full shortcut list and custom global rebinding. +- **Need a refresher?** Press `F1` for the searchable **Help Reference** and open **Help > Keyboard Shortcuts** for the full shortcut list and custom scoped rebinding. **Zoom and Scroll:** -- **Horizontal zoom**: `Ctrl+Scroll wheel` (or `Ctrl+Plus` / `Ctrl+Minus`). Zoom range: 1 to 1000 pixels per second. +- **Horizontal zoom**: `Ctrl+Scroll wheel` (or `Ctrl++` / `Ctrl+-`). Zoom range: 1 to 1000 pixels per second. - **Horizontal scroll**: Shift+scroll wheel, or use the horizontal scrollbar. - **Vertical scroll**: Scroll wheel when hovering over the track area. - **Zoom to Fit**: `Ctrl+0` resets zoom to a standard overview level. @@ -256,9 +267,9 @@ The Transport Bar runs along the bottom of the window and provides: **Center Section - Transport Controls:** - **Go to Start** (skip back icon): Returns the playhead to the beginning. - **Record** (red circle): Starts recording on armed tracks. `Ctrl+R` -- **Play** (green triangle): Starts playback. `Space` -- **Stop** (square): Stops playback/recording. `Space` (while playing) -- **Pause** (parallel bars): Pauses playback. +- **Play / Pause** (green triangle): Starts or pauses playback. `Space` +- **Stop** (square): Stops playback or recording. There is no separate default keyboard shortcut; `Space` stops an active recording through the Play / Pause action. +- **Pause** (parallel bars): Pauses playback in place. - **Loop** (repeat icon): Toggles loop playback mode. `L` - **Metronome** (metronome icon): Toggles the click track. @@ -301,8 +312,8 @@ OpenStudio includes several additional panels accessible via the View menu: | Big Clock | View menu | Large timecode display | | Render Queue | View menu | Queue and manage multiple render jobs | | Routing Matrix | View menu | Visual signal routing between tracks/buses | -| Media Explorer | View menu | Browse and preview media files | -| Media Pool | View menu | All media files used in the current project | +| Media Explorer | View menu | Browse/import media; backend audio preview is partial | +| Media Pool | View menu | State/actions exist; the full panel is not mounted yet | | Loudness Meter | View > Metering | LUFS loudness measurement | | Spectrum Analyzer | View > Metering | Frequency spectrum display | | Phase Correlation Meter | View > Metering | Stereo phase correlation | @@ -311,7 +322,7 @@ OpenStudio includes several additional panels accessible via the View menu: | Toolbar Editor | View menu | Customize toolbar layout | | Command Palette | `Ctrl+Shift+P` | Fuzzy search for any action in the application | | Help Reference | `F1` | Searchable in-app reference for controls and features | -| Keyboard Shortcuts | Help menu | Searchable shortcut reference and custom global rebinding | +| Keyboard Shortcuts | Help menu | Searchable reference, input profiles, and custom scoped rebinding | --- @@ -616,8 +627,8 @@ MIDI Learn allows you to map physical MIDI controller knobs, faders, and buttons **Clip selection:** - Click a clip to select it. -- `Ctrl+Click` to add/remove clips from the selection. -- `Shift+Click` for range selection. +- Primary+click to add/remove a clip from the selection (`Primary` is Ctrl on Windows/Linux and Command on macOS). +- Plain-drag empty timeline space to marquee-select every audio or MIDI clip intersecting the rectangle. - Click empty timeline background to deselect all clips. - `Ctrl+Shift+A` to select all clips. - `Esc` to deselect all. @@ -629,7 +640,7 @@ MIDI Learn allows you to map physical MIDI controller knobs, faders, and buttons - `Ctrl+A` to select all tracks. **Time selection:** -- Click and drag on the timeline ruler to create a time selection. +- Primary+drag on the timeline background, or Shift+drag on the ruler, to create a time selection (`Primary` is Ctrl on Windows/Linux and Command on macOS). - The time selection is highlighted as a shaded region. - Time selections are used for punch recording, rendering bounds, and editing operations. @@ -637,8 +648,9 @@ MIDI Learn allows you to map physical MIDI controller knobs, faders, and buttons - Click and drag a clip to move it to a new position or track. - When snap is enabled, the clip snaps to grid lines. -- Hold `Ctrl` while dragging to copy the clip instead of moving it. -- Hold `Shift` while dragging to constrain movement to horizontal only (time axis). +- Hold `Primary` while dragging to copy the clip instead of moving it. +- Hold `Shift` while dragging to lock movement to the first axis that crosses the drag threshold. +- Hold `Alt`/`Option` while dragging to bypass snapping. - Multi-selected clips move together as a group. ### 6.3 Splitting Clips @@ -665,7 +677,7 @@ Trim the start or end of a clip to reveal or hide content: Slip editing moves the audio content within a clip without changing the clip's position on the timeline: -1. Hold `Ctrl+Shift` and drag within a clip. +1. Hold `Primary+Shift` and drag within a clip (`Primary` is Ctrl on Windows/Linux and Command on macOS). 2. The clip's boundaries stay fixed, but the audio content slides earlier or later within the clip. 3. This is useful for adjusting the timing of audio relative to the clip boundaries. @@ -680,6 +692,9 @@ Each clip has adjustable fade-in and fade-out regions: **Auto-Crossfade**: When enabled (toggle in Main Toolbar or View menu), overlapping clips on the same track automatically create crossfades. +For an audio clip's gain envelope, Shift+click the clip to add a point. Drag an +existing point to change its time and gain; right-click a point to remove it. + ### 6.7 Undo and Redo OpenStudio provides comprehensive undo/redo support for virtually all editing operations: @@ -738,12 +753,12 @@ When a time selection is active, the following operations are available: Razor editing provides a fast way to select and delete specific regions across multiple tracks: -1. Hold `Alt` and drag on the timeline to create a razor selection area. +1. Hold `Alt`/`Option` and drag on the timeline background to create a razor selection area. 2. The razor area appears as a semi-transparent highlight. 3. Press `Delete` or use **Edit > Delete Razor Edit Content** to remove the content within the razor areas. -4. Use **Edit > Clear Razor Edits** to dismiss the razor selection without deleting. +4. Run **Clear Razor Edits** from the Command Palette to dismiss the razor selection without deleting. -Razor edits respect ripple mode settings. +Deleting razor-edit content currently leaves a gap; it does not apply ripple mode. ### 6.12 Ripple Editing @@ -869,8 +884,8 @@ MIDI Continuous Controller (CC) messages can be drawn and edited in CC lanes: Align note start times to the grid: 1. Select notes (or select all with `Ctrl+A` in the Piano Roll). -2. Press `Q` or go to **MIDI > Quantize Notes...** -3. The quantize dialog allows setting the quantize grid, strength, and whether to quantize note ends. +2. Open **MIDI > Quantize Notes...** to choose the grid, strength, and whether to quantize note ends. +3. Press `Q` to reapply the last-used MIDI quantize settings without reopening the panel. ### 7.9 MIDI Transform Operations @@ -905,7 +920,9 @@ The Piano Roll can highlight notes that belong to a specific musical scale: ### 7.11 Drum Editor -Toggle the Drum Editor mode via **View > Toggle Drum Editor**. The drum editor provides a grid-based view optimized for drum programming, where each row represents a drum instrument rather than a pitch. +The Drum Editor action/state is present, but a complete mounted drum-grid editor +is not part of the current workspace. Use the Piano Roll for MIDI drum-note +editing in this build. ### 7.12 Multi-Clip MIDI Editing @@ -1115,7 +1132,9 @@ Bypass all effects on a track without removing them: ### 9.8 FX Chain Reordering -Drag and drop effects within the FX Chain Panel to change their order. The signal flows from top to bottom through the chain. +Drag and drop effects within track and input FX chains to change their order. +The signal flows from top to bottom. Master-FX reordering is not supported in +the current UI. ### 9.9 Safe Mode (Bypass FX on Load) @@ -1125,6 +1144,54 @@ If a project with heavy or problematic plugins is slow to load, open it in Safe - All FX plugins are bypassed on load, allowing the project to open quickly. - You can then selectively enable plugins as needed. +### 9.10 NAM Rack + +**OpenStudio NAM Rack** is the built-in Guitar/Bass workspace for NAM A1/A2 +pedal, amp, and full-rig captures. + +1. Add **OpenStudio NAM Rack** from the built-in effects list and select the + live instrument input. +2. Choose **Guitar** or **Bass**. The profile changes appropriate hidden + tracking/frequency voicing and library filtering without replacing the + current capture, IR, or visible control values. +3. Load a local `.nam` file, or connect TONE3000 and open a tone pack. +4. If a pack contains multiple captures, choose **View Captures**, select the + exact child capture, and check its topology badge. **RAW / AMP ONLY** needs + an external cabinet IR; **CAB EMBEDDED** is a full rig and bypasses the + external cabinet stage while preserving the selected IR for later. +5. **Selecting** a row changes only the pending choice. **Audition** temporarily + routes that capture through live input. Audition another child to compare, + or choose **Stop**/Cancel to restore the previous rack state. +6. Choose **Use** to commit that exact capture. Reopen the capture selector and + use another child to replace it. +7. Use the Amp power control to bypass/enable the capture without unloading it, + or use **Unload** to clear the slot. The immediate rack recovery card handles + missing Amp/Cab assets with Locate, Replace, and Bypass. Project-open + missing-media recovery also covers Pedal NAM assets and can offer Search in + Folder, a library copy, Locate, and supported TONE3000 Re-download. +8. Save a rack tone or project to recall the complete creative state. Device + calibration remains local playback-environment state rather than silently + travelling as part of a tone. + +Factory effect templates contain control settings only: they do not include a +NAM capture or cabinet IR and use the Amp/Full-Rig that is already loaded. +Exported rack presets reference local NAM/IR files instead of embedding those +binaries, so another machine may require Locate, Search, or supported +Re-download recovery. + +Local `.nam` loading works without TONE3000. Public-build TONE3000 availability +depends on the partner-approved integration and release configuration. On +Linux, sign-in also requires `secret-tool` (usually installed by the +`libsecret-tools` package) and an available Secret Service/keyring. + +Audition and Use/Cancel are transactional, and prepared model swaps reject +stale requests. Perceived click/noise behavior on a real interface is still a +release audition item, especially at small buffers; automation alone is not a +substitute for listening to the exact build. + +See the [NAM Rack guide](nam-rack.md) for the complete signal chain, +Guitar/Bass mapping, state migration, TONE3000 connection, and QA contract. + --- ## 10. Automation @@ -1248,16 +1315,18 @@ Open the Render dialog via **File > Render...** or press `Ctrl+Alt+R`. ### 12.2 Render Source -Choose what to render: +Choose the required source. Master and track-stem paths are active; the two +selected-item choices are visible but do not yet filter the backend to the +selected clips: -| Source | Description | -|-----------------------------|-------------------------------------------------------| -| **Master mix** | Full stereo mix of all tracks through the master bus | -| **Selected tracks (stems)** | Individual stems for each selected track | -| **Master mix + all stems** | Master mix plus individual stems for every track | -| **Selected media items** | Only the selected clips, direct output | -| **Selected items via master** | Selected clips routed through the master FX chain | -| **Razor edit areas** | Render each razor edit area as a separate file | +| Source | Current status | +|---|---| +| **Master mix** | Full mix of all tracks through the master bus | +| **Selected tracks (stems)** | Individual stems for selected tracks | +| **Master mix + all stems** | Master mix plus a stem for every track | +| **Selected media items** | UI choice only; selected-clip filtering is pending | +| **Selected items via master** | UI choice only; selected-clip filtering is pending | +| **Razor edit areas** | Renders each razor-area time range as a separate stem from the track that owns the area | ### 12.3 Render Bounds @@ -1292,16 +1361,16 @@ Choose the time range to render: **Primary output format:** -| Format | Description | Bit Depth Options | -|-----------------|--------------------------------------|----------------------------| -| **WAV** | Standard uncompressed audio | 16-bit, 24-bit, 32-bit float | -| **AIFF** | Apple uncompressed audio | 16-bit, 24-bit, 32-bit float | -| **FLAC** | Lossless compressed audio | 16-bit, 24-bit | -| **MP3** | Lossy compressed (128-320 kbps) | N/A (bitrate-based) | -| **OGG Vorbis** | Lossy compressed (quality 3-10) | N/A (quality-based) | +| Format | Current status | +|---|---| +| **WAV** | Uncompressed output with the available bit-depth selection | +| **AIFF** | Uncompressed output with the available bit-depth selection | +| **FLAC** | Lossless compressed output with the available bit-depth selection | +| **MP3** | FFmpeg-encoded output using the selected bitrate; FFmpeg is bundled on Windows and must be installed on `PATH` on macOS/Linux | +| **OGG Vorbis** | FFmpeg-encoded output using the selected quality; FFmpeg is bundled on Windows and must be installed on `PATH` on macOS/Linux | -**Sample rate**: 44100, 48000, 88200, 96000, or 192000 Hz. -**Note**: Rendering processes through the current engine/device configuration, then post-processes when a target sample-rate conversion is requested. +**Sample rate**: Select the target render rate. The offline engine renders at +that rate while the playback path converts source files as required. **Channels**: Stereo or Mono. @@ -1310,16 +1379,13 @@ Choose the time range to render: | Option | Description | |-----------------|------------------------------------------------------------------| | **Normalize** | Peak-normalizes the output to 0 dBFS | -| **Dither** | Applies dither when reducing bit depth. Types: TPDF, Noise Shaped. Only available for 16-bit and 24-bit output. | +| **Dither** | Applies TPDF or first-order noise-shaped dither before integer bit-depth output | | **Resample Quality** | UI placeholder only in the current build; backend support is pending | ### 12.7 Secondary Output -Enable **Secondary output** to simultaneously render a second format (e.g., render WAV master + MP3 reference): - -1. Check "Secondary output". -2. Select the secondary format (MP3, OGG, FLAC, WAV, AIFF). -3. Select the secondary bit depth. +Enable **Secondary output** to run a second render pass in another format after +each primary file. Select its format and bit depth/codec quality independently. ### 12.8 Metadata @@ -1414,11 +1480,9 @@ Archive your entire session (project file + all referenced media) into a single ### 13.8 Media Pool -The Media Pool (**View > Media Pool** or **File > Media Pool**) lists all audio and MIDI files used in the current project: - -- View file paths, durations, sample rates, and channel counts. -- Identify missing media files. -- Remove unused media references. +Media-pool state and menu actions are present, but the full Media Pool panel is +not mounted in the current workspace. Use the Missing Media Resolver for broken +paths and the Media Explorer for browsing/import. ### 13.9 Missing Media Resolver @@ -1645,15 +1709,30 @@ For custom theming, open **View > Theme Editor...**. The Theme Editor allows you ### 15.3 Keyboard Shortcuts -Open the **Keyboard Shortcuts** window from the **Help** menu to browse the searchable shortcut reference, print a cheat sheet, and rebind supported shortcuts. +Open **Help > Keyboard Shortcuts** to browse the searchable action reference, +print a cheat sheet for the current platform, choose input profiles, and rebind +supported actions. Press `F1` for the **Help Reference**, which is separate from the Keyboard Shortcuts window. Use **Help > Getting Started Guide** for the built-in first-session walkthrough covering navigation gestures, essential hotkeys, track creation, recording, and export. -Custom shortcut rebinding currently lives in the **Keyboard Shortcuts** window, not in Preferences. +Keyboard and **Mouse & scroll** profiles are selected independently. The 19 +built-in profile families are OpenStudio, Pro Tools, Cubase/Nuendo, REAPER, +Audacity, Logic Pro, FL Studio, Ableton Live, Studio One, Bitwig Studio, +Reason, Cakewalk/Sonar, GarageBand, Digital Performer, Ardour, Adobe Audition, +Mixcraft, Waveform, and Renoise. -Custom rebinding currently applies to **global shortcuts**. Timeline- and editor-scoped shortcuts are documented in the reference but are not rebindable in this pass. +Create named custom keyboard profiles to add multiple bindings, set separate +macOS/Windows/Linux/fallback overrides, intentionally unassign an action, +inherit its base mapping again, and import/export the profile as JSON. Conflict +checks run before an overlapping key is accepted. + +Bindings can be scoped to global, Timeline/ruler, track controls, Mixer, Piano +Roll, Pitch Editor, automation, browser, plug-in, modal, and contextual +surfaces. Custom shortcut editing lives in the Keyboard Shortcuts window, not +in Preferences. See [Keyboard, Hotkey, Mouse, and Scroll +Profiles](input-profiles.md) for the full behavior and safety rules. ### 15.4 Preferences @@ -1677,10 +1756,12 @@ Open **Options > Preferences** (`Ctrl+,`) to access the full preferences dialog: - Panel visibility settings **Mouse tab:** +- Choose the active **Mouse & scroll profile** independently from the keyboard map. - Configure what happens when you click with different modifier keys in various contexts: - Clip Drag, Clip Resize, Timeline Click, Track Header, Automation Point, Fade Handle, Ruler Click - - Each context can have different actions for: Click, Ctrl+Click, Shift+Click, Alt+Click -- Reset to defaults button + - Each context exposes all 16 combinations of semantic Primary, Secondary, Alt/Option, and Shift +- **Clear Custom Overrides** removes the persisted fine-grained overrides and returns to the selected base profile. +- The selected mouse/scroll base profile and validated per-gesture overrides persist and are shared with detached windows. **Backup tab:** - Enable/disable auto-backup @@ -1730,7 +1811,7 @@ Open **View > Toolbar Editor...** to customize the Main Toolbar: ### 15.8 Command Palette -Press `Ctrl+Shift+P` to open the Command Palette. Type to fuzzy-search through all available actions. Press Enter to execute the selected action. This is the fastest way to access any feature without memorizing its shortcut or menu location. +Press `Ctrl+Shift+P` to open the Command Palette. Type to fuzzy-search through all available actions. Press Enter to execute the selected action. This lets you access actions without memorizing their shortcut or menu location. ### 15.9 Plugin Bridge (32-bit) @@ -1743,14 +1824,19 @@ If you have 32-bit VST plugins that need to run in the 64-bit OpenStudio environ ## 16. Keyboard Shortcuts +The tables below show the **OpenStudio default keyboard profile**. Other +built-in profiles, platform-specific bindings, custom overrides, and active +editor scopes can change or intentionally unassign these keys. Use **Help > +Keyboard Shortcuts** for the effective map. + ### 16.1 Transport | Action | Shortcut | |-----------------------|-------------------------| | Play / Pause | `Space` | -| Stop | `Space` (while playing) | +| Stop | No separate default | | Record | `Ctrl+R` | -| Go to Start | Transport button / command palette | +| Go to Start | `Home` | | Toggle Loop | `L` | | Set Loop to Selection | `Ctrl+L` | | Tap Tempo | `T` | @@ -1823,8 +1909,8 @@ If you have 32-bit VST plugins that need to run in the 64-bit OpenStudio environ | Help Reference | `F1` | | Keyboard Shortcuts | Help menu | | Zoom to Time Selection | `Ctrl+Shift+E` | -| Zoom In | `Ctrl+Plus` | -| Zoom Out | `Ctrl+Minus` | +| Zoom In | `Ctrl++` | +| Zoom Out | `Ctrl+-` | | Zoom to Fit | `Ctrl+0` | | Save Screenset 1 | `Ctrl+Shift+1` | | Save Screenset 2 | `Ctrl+Shift+2` | @@ -1852,7 +1938,7 @@ If you have 32-bit VST plugins that need to run in the 64-bit OpenStudio environ | Action | Shortcut | |----------------------------|----------| -| Quantize Notes | Quantize dialog / command palette | +| Quantize Notes Using Last Settings | `Q` | | Transpose +1 Semitone | (via menu/command palette) | | Transpose -1 Semitone | (via menu/command palette) | | Transpose Octave Up (+12) | (via menu/command palette) | @@ -1861,31 +1947,37 @@ If you have 32-bit VST plugins that need to run in the 64-bit OpenStudio environ | Velocity -10% | (via menu/command palette) | | Reverse MIDI Notes | (via menu/command palette) | | Invert MIDI Note Pitches | (via menu/command palette) | -| Select All Notes | (via menu/command palette) | - -### 16.10 Mouse Shortcuts - -| Action | Mouse Gesture | -|---------------------------|----------------------------------------| -| Vertical navigate | Scroll | -| Timeline zoom | Ctrl+Scroll | -| Horizontal navigate | Shift+Scroll | -| Resize track height | Alt+Scroll | -| Faster track-height zoom | Ctrl+Shift+Scroll | -| Move clip | Drag clip | -| Copy clip | Ctrl+Drag clip | -| Constrain to horizontal | Shift+Drag clip | -| Slip edit | Alt+Drag inside clip | -| Trim clip edge | Drag left/right edge of clip | -| Create fade | Drag top-left or top-right corner | -| Add gain point | Shift+Click in clip | -| Rubber-band select clips | Drag on empty timeline space | -| Create razor edit | Alt+Drag on timeline | -| Horizontal zoom | Ctrl+Scroll wheel (on timeline) | -| Horizontal scroll | Shift+Scroll wheel | -| Move playhead | Click on ruler | -| Create time selection | Drag on ruler | -| Context menu | Right-click | +| Select All Notes | `Ctrl+A` | + +### 16.10 Mouse Shortcuts (OpenStudio Profile) + +`Primary` means Ctrl on Windows/Linux and Command on macOS. + +| Surface / action | OpenStudio mouse gesture | +|---|---| +| Vertical workspace scroll | Scroll | +| Timeline zoom | Primary+Scroll | +| Horizontal timeline scroll | Shift+Scroll | +| Resize track height | Alt/Option+Scroll | +| Zoom waveform height | Primary+Shift+Scroll | +| Move / copy clip | Drag / Primary+Drag | +| Slip-edit clip contents | Primary+Shift+Drag | +| Axis-lock clip move | Shift+Drag; locks to the first axis that crosses the threshold | +| Move clip without snap | Alt/Option+Drag | +| Resize / fine resize clip edge | Drag / Primary+Drag edge | +| Symmetric resize / stretch clip | Shift+Drag / Alt/Option+Drag edge | +| Seek on empty timeline | Click | +| Select range / extend selection | Primary+Click-drag / Shift+Click-drag | +| Create a razor edit | Alt/Option+Drag empty timeline | +| Select / toggle / range-select track | Click / Primary+Click / Shift+Click track header | +| Solo track | Alt/Option+Click track header | +| Move / fine-move automation point | Drag / Primary+Drag | +| Constrain automation point vertically / delete | Shift+Drag / Alt/Option+Click point | +| Adjust / fine-adjust fade handle | Drag / Primary+Drag | +| Symmetric fade / cycle fade shape | Shift+Drag / Alt/Option+Click handle | +| Seek from ruler | Click ruler | +| Set loop / time selection / zoom range | Primary+Drag / Shift+Drag / Alt/Option+Drag ruler | +| Context menu | Right-click | --- @@ -1951,7 +2043,8 @@ If you have 32-bit VST plugins that need to run in the 64-bit OpenStudio environ **Solutions**: 1. When prompted, use the Missing Media dialog to browse for the moved files. 2. If files were moved, point to their new location. -3. Use **File > Media Pool** to view all referenced files and their status. +3. Use the Missing Media dialog to resolve each referenced path; the full Media + Pool panel is not mounted in the current workspace. 4. If original files are lost, re-record or re-import the audio. ### 17.7 Recording Issues @@ -1965,6 +2058,32 @@ If you have 32-bit VST plugins that need to run in the 64-bit OpenStudio environ 4. Confirm audio signal is reaching the track (check the activity meter on the Track Header). 5. Check **Record Safe** is not enabled on the track (prevents recording). +#### macOS microphone permission recovery + +macOS uses the **Microphone** privacy permission for every audio input, +including built-in microphones and USB/Thunderbolt audio interfaces. A device +can appear in Audio Settings while its captured samples remain silent if access +was denied. + +1. Quit OpenStudio. +2. Open **System Settings > Privacy & Security > Microphone** and enable + OpenStudio. +3. Reopen OpenStudio, select the input device again, arm a track, and verify the + input meter. + +If OpenStudio is missing from that list or the stored decision appears stuck, +you can optionally reset permission for only the installed OpenStudio bundle: + +```bash +bundle_id="$(defaults read /Applications/OpenStudio.app/Contents/Info CFBundleIdentifier)" +tccutil reset Microphone "$bundle_id" +``` + +If the app is installed elsewhere, adjust the path in the first command. Then +relaunch OpenStudio and choose **Allow** when macOS asks. Avoid the broader +`tccutil reset Microphone` command unless you intentionally want to reset +microphone permission for every application. + ### 17.8 Project Won't Save **Symptoms**: Save fails or project file appears empty. @@ -2024,15 +2143,21 @@ If you have 32-bit VST plugins that need to run in the 64-bit OpenStudio environ **Solutions**: 1. Ensure the main OpenStudio window has focus (click on the timeline or a panel). 2. If a text input field is focused (e.g., renaming a track), keyboard shortcuts are temporarily disabled. Press `Esc` to defocus. -3. Check the keyboard shortcuts reference (`F1`) to confirm the correct binding. -4. Open **Help > Keyboard Shortcuts** to confirm whether a global shortcut was customized or reset it to the default binding. -5. Some shortcuts are context-dependent (e.g., MIDI shortcuts only work when the Piano Roll is open). +3. Open **Help > Keyboard Shortcuts**. `F1` opens the separate Help Reference, + not the active key map. +4. Confirm the selected keyboard profile, the current platform override, and + whether the action is intentionally unassigned. +5. Check the action's scope. Timeline, Piano Roll, Pitch Editor, Mixer, + automation, browser, plug-in, track-control, and modal bindings run only in + their matching context. +6. If a custom profile is active, choose **Inherit** for one target or reset the + profile to compare against its built-in base map. --- ## 18. AI Music and Assisted Audio -OpenStudio includes optional AI-assisted workflows that live inside the normal DAW session. These features are not bundled into the base app by default; install the required AI Tools runtime from inside the app when prompted. +OpenStudio includes AI-assisted workflows that live inside the normal DAW session. The small Basic Pitch model is bundled, with audio-to-MIDI inference enabled in current ONNX-enabled Windows/Linux releases. Generation and stem workflows use optional AI Tools runtimes and large model assets installed on demand. ### 18.1 AI Tools Setup @@ -2087,9 +2212,9 @@ OpenStudio project files (`.osproj`) are saved to the location you choose when s ## Appendix B: Audio Format Support -**Import formats**: WAV, AIFF, FLAC, MP3, OGG Vorbis, MIDI, and video audio extraction where FFmpeg is available +**Import formats**: WAV, AIFF, FLAC, MP3, OGG Vorbis, MIDI, and video audio extraction where FFmpeg is available. The audited executable is bundled on Windows; FFmpeg-backed operations require a system `ffmpeg` on `PATH` on macOS/Linux. -**Export formats**: WAV, AIFF, FLAC, MP3, OGG Vorbis, MIDI, and DDP export +**Export formats**: WAV, AIFF, FLAC, MP3, OGG Vorbis, MIDI, and DDP export. MP3/OGG and other FFmpeg conversions require the bundled Windows executable or a system `ffmpeg` on macOS/Linux. **Plugin formats**: VST3 is the stable primary path. CLAP and LV2 code paths are present where available. Experimental 32-bit bridging controls are not part of the stable plugin-hosting path. @@ -2163,12 +2288,12 @@ OpenStudio generates `.ospeaks` sidecar files alongside audio files for efficien - OpenStudio handles sample rate conversion automatically when importing audio files recorded at different rates than the project's device rate. - Linear interpolation is used for real-time sample rate conversion during playback. -- For rendering, the "Resample Quality" setting (Fast/Good/Best) controls the quality of the conversion algorithm. +- Offline rendering supports the selected target sample rate. The **Resample Quality** selector is currently a UI placeholder and does not yet change the backend conversion algorithm. ### Audio Thread Safety -OpenStudio uses professional-grade audio thread safety patterns: -- Non-blocking locks on the audio thread (try-lock pattern) ensure glitch-free playback. +OpenStudio uses the following audio-thread safety patterns: +- Non-blocking locks on the audio thread (try-lock pattern) reduce the risk of callback stalls. - Pre-allocated audio buffers avoid heap allocations during audio processing. - Pre-loaded audio file readers prevent disk I/O on the audio thread. - Atomic operations for parameter updates (volume, pan) avoid mutex contention. diff --git a/docs/audio_midi_engine_roadmap.md b/docs/audio_midi_engine_roadmap.md deleted file mode 100644 index 771934f..0000000 --- a/docs/audio_midi_engine_roadmap.md +++ /dev/null @@ -1,88 +0,0 @@ -# Audio/MIDI Engine Roadmap - -## Summary -Implement this in phases so we fix correctness first, then complete plugin-format and low-latency MIDI support, then add the 64-bit hybrid engine without destabilizing the current app. The first shippable milestone is a repo-backed fix for live MIDI keyboard input, instrument-track rendering, MIDI clip playback, and offline/render parity. Later phases complete CLAP/VST3i behavior and add an opt-in hybrid 64-bit processing mode with guarded rollout. - -## Phase Plan -### Phase 1: MIDI/Instrument Bugfixes and Correctness -Status: In progress. Core engine work is implemented; live keyboard/render validation is still pending. -- Replace the current placeholder live-MIDI path with an enqueue-only design; no instrument/plugin processing outside the audio thread. -- Add per-track live MIDI intake so hardware MIDI, virtual keyboard input, and future MIDI clip playback all enter the same track-owned event path. -- In the real-time audio callback, build each track's block `MidiBuffer` before track processing. -- Process the loaded instrument plugin inside the real-time track path for `Instrument` tracks, before post-instrument audio FX. -- Process the loaded instrument plugin in offline render, freeze, bounce, and stem/export paths so live playback and render use the same signal flow. -- Ensure transport stop, loop wrap, track mute/solo, record-arm changes, and monitor-off events flush pending note-offs safely. -- Keep current float audio behavior unchanged for non-MIDI tracks. -- Acceptance for this phase: a user can load a VST3i, arm/monitor the track, play a MIDI keyboard, hear sound in real time, record MIDI, and get matching offline render output. - -### Phase 2: Full MIDI Track and Clip Playback Implementation -Status: Partially implemented. Track MIDI scheduling/sync is in place; loop/punch hardening and downstream/plugin MIDI routing still need completion. -- Introduce an engine-side MIDI clip scheduler that converts stored clip note/CC events into per-block sample-offset MIDI events. -- Merge three MIDI sources per track in the audio thread: scheduled clip events, queued live input, and UI-generated input. -- Add proper loop handling, seek handling, punch-in/out behavior, and overdub-safe note state tracking. -- Route MIDI-only tracks to hardware MIDI output and instrument tracks to instrument plugins; do not silently drop MIDI on either path. -- Make plugin MIDI output a first-class path: capture plugin-produced MIDI from instrument/MIDI effects and route it either to hardware output or downstream MIDI targets. -- Preserve existing frontend clip models and NativeBridge calls where possible; add backend-only scheduling first, then tighten JS/native contract only if needed. -- Acceptance for this phase: MIDI clips play back sample-aligned, overdub records correctly, loop playback does not hang notes, and MIDI-only tracks can drive external hardware. - -### Phase 3: VST3i and CLAP Completion -Status: Partially implemented. CLAP JUCE MIDI bridging and capability probing exist; transport/playhead parity and full browser/runtime parity are still pending. -- Keep VST3 hosting as the primary reference path and make instrument-track processing identical for VST3 effects, VST3 instruments, and CLAP instruments. -- Complete CLAP event bridging by translating JUCE MIDI events into CLAP note/parameter events with intra-block sample offsets. -- Implement CLAP output-event handling so CLAP plugins that emit MIDI/events are not treated as no-op outputs. -- Pass transport/playhead timing into CLAP processing just as VST3 plugins already receive playhead state. -- Detect plugin capabilities up front: instrument, MIDI effect, audio effect, MIDI output, double-precision support, bus layouts. -- Classify plugins in the browser using actual capabilities so instrument-only and MIDI-effect-only flows are correct. -- Acceptance for this phase: at least one VST3i and one CLAP instrument both work for live keyboard input, MIDI clip playback, editor open/state restore, and offline render. - -### Phase 4: Ultra-Low-Latency MIDI Architecture -Status: Partially implemented. The MIDI input callback path is lock-free, the real-time callback now runs from immutable processing snapshots instead of the main graph try-lock, busy-track contention can now fall back to the last safe processed block instead of hard-dropping immediately, and busy master/monitor chains now reuse the last safe processed output instead of forcing silence; deeper end-to-end hardening and validation are still pending. -- Remove blocking locks from the hot MIDI path; use preallocated per-device lock-free queues for inbound MIDI and a separate queue for UI/virtual-keyboard MIDI. -- Timestamp input events on arrival and convert them to audio-block sample offsets when draining into the audio thread. -- Add per-track note-state tables and overflow counters so dropped/late events are observable and recoverable. -- Keep all queue draining, event merging, and note-off recovery allocation-free on the audio thread. -- Reduce avoidable callback stalls around graph edits by isolating MIDI delivery from graph-management locks. -- Add a diagnostics view/API for queue overflow count, late-event count, max events per block, and current buffer size. -- Acceptance for this phase: stable live keyboard play at 32/64/128 sample buffers with no hung notes, no callback-time locking in the MIDI path, and measurable lower worst-case input-to-sound jitter. - -### Phase 5: 64-Bit Hybrid Engine -Status: Partially implemented. A processing-precision framework now exists with project persistence, track/plugin double-precision negotiation, sidechain-aware double processing, freeze/render MIDI parity, hybrid64 now reaching real-time and offline master/master-monitor processing more deeply where plugins support it, and per-plugin float fallback overrides are now exposed in the FX/monitoring UI for track/master/monitor chains; the full end-to-end double bus architecture is still not complete yet. -- Add an engine setting `processingPrecision: float32 | hybrid64`, persisted per application/session setting; default stays `float32` initially. -- Implement double-precision internal buses for track summing, sends, sidechains, master summing, and offline render when `hybrid64` is enabled. -- Negotiate plugin precision per instance: if a plugin supports double precision, process it in double; otherwise convert at the plugin boundary and continue internal summing in double. -- Keep media decode and hardware I/O compatible with current float paths while promoting internal summing to double in hybrid mode. -- Update automation, metering, PDC, sidechain routing, and normalization/render code so float and hybrid64 paths stay behaviorally aligned. -- Add a compatibility fallback so any plugin that misbehaves in double can be forced to float processing without disabling hybrid64 globally. -- Acceptance for this phase: hybrid64 renders null-close against float for ordinary sessions, improves precision in stress mixes/feedback-heavy cases, and does not change existing float32 session behavior unless the setting is enabled. - -### Phase 6: Hardening, Compatibility, and Release Guardrails -Status: Partially implemented. Backend compatibility-matrix and engine-benchmark APIs now exist, benchmarks now cover multiple block sizes and include MIDI diagnostic context plus hybrid64-aware master processing, the release-guardrail runner now validates track/master/monitor fallback counters, compatibility-metadata completeness, and benchmark block coverage/finite values in one backend report, and an automated regression-suite runner plus repo-side runner script now exist; full regression coverage and strict release gating are still not complete. -- Add plugin regression coverage for mono/stereo instruments, sidechain FX, MIDI-only plugins, CLAP instruments, and float-only legacy plugins. -- Benchmark CPU and memory cost of `float32` vs `hybrid64` at 32/64/256 sample buffers and at realistic large-session track counts. -- Gate release on keyboard-smoke tests, offline-render parity tests, no-hung-note tests, and queue-overflow diagnostics staying clean under stress. -- Keep a rollback switch for `hybrid64` and for CLAP-MIDI output handling until the compatibility matrix is green. - -## Important API and Interface Changes -- Backend engine additions: - `enqueueLiveMidiEvent`, `enqueueUiMidiEvent`, `buildTrackMidiBlock`, `processInstrumentTrack`, `setProcessingPrecision`, `getMidiDiagnostics`, `getPluginCapabilities`. -- Track model additions: - per-track live MIDI queue, per-track note-state cache, optional downstream MIDI target, optional per-plugin precision override. -- Native bridge additions: - precision setting getter/setter, MIDI diagnostics getter, optional keyboard-test diagnostics endpoint, regression-suite runner endpoint. -- Plugin capability model: - add booleans for `isInstrument`, `isMidiEffect`, `producesMidi`, `supportsDoublePrecision`, `pluginFormat`, and bus-layout summary. - -## Test Plan -- Live keyboard test: load VST3i, arm + monitor, play notes/chords rapidly, stop transport, verify no hung notes. -- MIDI clip test: import/create clip with note-on/off and CC data, play, loop, seek, and render; verify live and render outputs match. -- Hardware MIDI test: use a MIDI-only track to drive an external device and verify note timing and note-off behavior. -- CLAP test: scan/load a CLAP instrument, open editor, play live MIDI, play MIDI clips, save/restore state, render offline. -- Latency test: run at 32/64/128 sample buffers and capture queue overflow, late-event count, and callback-stall metrics. -- Hybrid64 test: compare float32 vs hybrid64 CPU use, render parity, sidechain behavior, PDC correctness, and plugin fallback handling. - -## Assumptions and Defaults -- Default shipping behavior remains `float32` until hybrid64 passes compatibility and performance validation. -- MIDI correctness takes priority over preserving the current placeholder monitor path; any non-audio-thread instrument triggering is removed. -- VST3 remains the most stable plugin path; CLAP reaches feature parity after the dedicated event-bridge phase, not before. -- Existing frontend track/clip models stay intact unless Phase 2 proves a contract change is required for sample-offset scheduling. -- The first user validation checkpoint is after Phase 1, using a physical MIDI keyboard on an instrument track before broader roadmap work continues. diff --git a/docs/audio_signal_chain_qa.md b/docs/audio_signal_chain_qa.md deleted file mode 100644 index 8051126..0000000 --- a/docs/audio_signal_chain_qa.md +++ /dev/null @@ -1,32 +0,0 @@ -# Audio Signal Chain QA - -Pitch, render, and playback artifact work must start with the signal chain before any DSP tuning. - -## Chain References - -- Live playback: clip route resolution -> `PlaybackEngine` read/mix -> `TrackProcessor` -> sends/sidechain -> master/monitoring FX -> gain/pan/mono -> meters/spectrum -> audio device. -- Offline render/export: `renderProject` -> render `PlaybackEngine` snapshot -> `TrackProcessor` -> master FX/gain -> writer. - -If a click/noise is present in an exported render, debug the shared render/playback path first. Do not attribute it to WebView IPC, audio-device underruns, or live-only preview routing until the render chain is clean. - -## Render Chain Debug Packet - -Set `OPENSTUDIO_AUDIO_CHAIN_DEBUG=1` before rendering to emit a debug packet next to the rendered file, or set `OPENSTUDIO_AUDIO_CHAIN_DEBUG_DIR` to choose a folder. The packet includes: - -- `render_chain_report.json` -- `playback_output.wav` -- `track_post_processing.wav` -- `master_pre_fx.wav` -- `master_post_fx.wav` -- `writer_input.wav` - -The report records per-block route/source details and peak/RMS/high-derivative/non-finite stats. Use `OPENSTUDIO_AUDIO_CHAIN_DEBUG_MAX_SEC` to cap the captured duration; the default is `12` seconds. - -## First Dirty Stage Rule - -- Dirty at playback: inspect source routing, stale pitch-preview state, reader/sample-rate conversion, chunking, and hot-path allocation counters. -- Dirty after track/master processing: inspect no-FX bypass, processor reset/state, default EQ/gain, and denormal handling. -- Dirty only at writer output: inspect write alignment, format conversion, dither, and block/tail handling. -- Dirty only in live playback after clean render: inspect callback duration/deadline counters, spectrum lock misses, callback resizes, IPC/timer pressure, and transport preview cleanup. - -Do not call a render-noise fix done until the first dirty stage is identified and the fixed output passes spectrogram/high-derivative checks plus user audition. diff --git a/docs/cubase-grid-quantize-parity-plan.md b/docs/cubase-grid-quantize-parity-plan.md deleted file mode 100644 index 8191090..0000000 --- a/docs/cubase-grid-quantize-parity-plan.md +++ /dev/null @@ -1,34 +0,0 @@ -# Cubase-Style Global Grid, Snap, and MIDI Quantize Parity - -## Summary -Implement Cubase-style parity for the global grid, snap behavior, MIDI editor snapping, and MIDI quantize workflow. This pass excludes audio warp/hitpoint quantize, groove extraction from audio, and backend DSP changes. - -## Already Completed -- [x] Reviewed Steinberg Cubase 15 docs for Quantize Panel, Quantize Presets, Grid Type, Snap Grid, Snap Types, Key Editor Toolbar, and MIDI snap behavior. -- [x] Audited Studio13's current global snap, timeline grid, piano roll snap, and MIDI quantize implementation. - -## Tracking Checklist -- [x] Create `docs/cubase-grid-quantize-parity-plan.md` with this checklist. -- [x] Add shared grid/quantize preset model and interval resolver. -- [x] Persist new snap/grid/quantize state in store and project save/load. -- [x] Add header toolbar visual controls. -- [x] Update View menu and Preferences grid controls. -- [x] Refactor timeline snapping and grid rendering to use shared resolver. -- [x] Refactor piano roll snapping and grid rendering to use shared resolver. -- [x] Implement MIDI Ctrl snap-bypass and resolve duplicate-drag conflict. -- [x] Replace MIDI Quantize dialog with Cubase-style Quantize Panel. -- [x] Add Length Quantize and Quantize Link behavior for MIDI editor/step input. -- [x] Implement custom quantize preset save/rename/remove/restore factory. -- [x] Add tests and mark checklist items done as each passes. - -## Implemented Visual Changes -- [x] Main header toolbar now shows Snap, Snap Type, Grid Type, Quantize Preset, Apply Quantize, and Quantize Panel controls. -- [x] Piano roll toolbar now mirrors the Cubase-style Snap, Snap Type, Grid Type, Quantize Preset, Apply, Quantize Panel, and Length Quantize controls. -- [x] Piano roll status strip displays the active resolved grid label instead of the old fixed `0.25 beat` snap readout. -- [x] MIDI Quantize dialog is now a Quantize Panel-style grid with Preset, Mode, Grid Type, Soft Quantize, Tuplet, Swing, Groove, Catch Range, Safe Range, Rough Quantize, Length Quantize, Move Controllers, Auto Apply, Reset, and Apply controls. - -## Test Plan -- [x] Unit-test straight, triplet, dotted, Bar/Beat, Use Quantize, and Adapt to Zoom interval resolution. -- [x] Unit-test Snap Type behavior for grid, relative grid, cursor, event, and combined snap candidates. -- [ ] Add MIDI editor interaction coverage for Snap on, Ctrl-drag off-grid placement, draw/resize/split using selected grid, quantize using current preset, and Length Quantize. -- [x] Run `npx tsc --noEmit` and targeted frontend tests; note known pre-existing TypeScript errors separately if still present. diff --git a/docs/design-screenshot-gap-review.md b/docs/design-screenshot-gap-review.md deleted file mode 100644 index 663c54c..0000000 --- a/docs/design-screenshot-gap-review.md +++ /dev/null @@ -1,642 +0,0 @@ -# Design Screenshot Gap Review - -## Summary - -This document compares the supplied design screenshots against the current OpenStudio frontend and records what is already implemented, what is partial, what is still missing, and what depends on backend support. - -Scope notes: - -- The screenshots are treated as the design source of truth. -- This review targets screenshot parity, not full PRO DAW parity beyond what is visibly represented. -- Status labels used in this document: - - `Implemented`: the visible surface and primary behavior already exist. - - `Partial`: some of the visible surface or behavior exists, but parity is incomplete. - - `Missing`: the screenshot-visible surface is not exposed yet. - - `Backend-dependent`: the frontend surface exists or is planned, but full behavior still depends on backend support. - -## Current App Baseline - -The main DAW shell is already present and wired in the current app: - -- `ProjectTabBar`, `MenuBar`, timeline workspace, lower editor zones, mixer, and transport are mounted from `frontend/src/App.tsx`. -- The app already ships major dialogs and panels referenced by the screenshots, including: - - `RenderModal` - - `ProjectSettingsModal` - - `RenderQueuePanel` - - `UndoHistoryPanel` - - `CommandPalette` - - `DynamicSplitModal` - - `RegionRenderMatrix` - - `CleanProjectModal` - - `BatchConverterModal` - - `CrossfadeEditor` - - `ClipPropertiesPanel` - - `PianoRoll` - - `VirtualPianoKeyboard` - -Key validation already completed during this audit: - -- `frontend` tests pass: `51/51` -- `frontend` production build succeeds - -## Screenshot Audit - -### `design.png` - -Status: `Partial` - -What matches today: - -- Main DAW shell exists with a top menu bar, track control panel, timeline, mixer area, and bottom transport. -- Track headers already support record arm, mute, solo, FX, input selection, color, icon, and automation-oriented controls. -- Mixer and transport surfaces are implemented as app-level panels rather than placeholders. - -What is still off: - -- Top-level menu structure does not yet match the screenshot order. -- The screenshot shows `File`, `Edit`, `View`, `Insert`, `Item`, `Track`, `Options`, `Actions`, `Help`. -- The current app renders only `File`, `Edit`, `View`, `Insert`, `Options`, `Help`. -- The screenshot suggests a more PRO DAW-aligned discoverability flow for item and track operations than the current top menu exposes. - -Relevant implementation: - -- `frontend/src/App.tsx` -- `frontend/src/components/MenuBar.tsx` -- `frontend/src/components/TrackHeader.tsx` -- `frontend/src/components/SortableTrackHeader.tsx` -- `frontend/src/components/Timeline.tsx` -- `frontend/src/components/MixerPanel.tsx` -- `frontend/src/components/TransportBar.tsx` - -### `Design screenshots/File-options.png` - -Status: `Partial` - -Already implemented: - -- New project -- Open project -- Save project -- Save project as -- Close project -- Project settings -- Render -- Region Render Matrix -- Export project MIDI -- Clean project directory -- Batch file converter -- Quit -- Recent projects -- Template-related operations -- Safe mode project open - -Differences from screenshot: - -- `New project tab` and `Save all projects` are not surfaced in the File menu today, even though project tabs exist. -- `Queued Renders` is represented as a separate `Render Queue` panel, but not exposed with the same wording and placement as the screenshot. -- `Project Render Metadata` is not a complete File-menu workflow yet. -- `Save live output to disk (bounce)...` is represented as `Capture Output`, which is functionally related but not named or placed as screenshot parity. -- `Consolidate/Export tracks...` is not surfaced from the File menu as shown. - -Relevant implementation: - -- `frontend/src/components/MenuBar.tsx` -- `frontend/src/components/RenderQueuePanel.tsx` -- `frontend/src/store/actionRegistry.ts` - -### `Design screenshots/Edit-options.png` - -Status: `Partial` - -Already implemented: - -- Undo / redo -- Undo history -- Select all -- Cut / copy / paste -- Cut within time selection -- Copy within time selection -- Dynamic split - -Implemented elsewhere but not surfaced like the screenshot: - -- Crossfade editor exists -- Nudge operations exist -- Split operations exist -- Group / ungroup exist -- Reverse clip exists -- Quantize selected clips exists - -Current parity gaps: - -- The current `Edit` menu mixes general edit operations with clip/item-specific operations that should likely move into a top-level `Item` menu for screenshot parity. -- Several screenshot-visible operations exist in store actions or context menus but are not exposed with the same labels, grouping, or separators. -- `Transient Detection Settings` is not surfaced as a dedicated menu entry; transient detection currently lives inside `DynamicSplitModal`. - -Relevant implementation: - -- `frontend/src/components/menus/EditMenu.tsx` -- `frontend/src/components/DynamicSplitModal.tsx` -- `frontend/src/components/CrossfadeEditor.tsx` -- `frontend/src/store/actionRegistry.ts` -- `frontend/src/store/actions/clipEditing.ts` - -### `Design screenshots/View-options.png` - -Status: `Partial` - -Already implemented: - -- Mixer toggle -- Virtual MIDI keyboard -- Region/marker manager -- Clip properties -- Big clock -- Render queue -- Routing matrix -- Media explorer -- Audio settings -- Render -- Region Render Matrix -- Zoom controls -- Loop enable -- Snap enable -- Screensets - -Partially matching: - -- The View menu contains many screenshot-related destinations, but naming and ordering do not yet follow the screenshot. -- Some screenshot items are represented by OpenStudio-specific equivalents instead of direct label matches. - -Still missing or not surfaced cleanly: - -- `Track Manager` -- `Track Group Manager` -- `Project Media/FX Bay` -- `Navigator` -- `Scale Finder` -- `Show/hide all floating windows` -- `Cascade all floating windows` -- `Time unit for ruler` -- `Go to` -- `Always on top` -- `Fullscreen` - -Relevant implementation: - -- `frontend/src/components/MenuBar.tsx` -- `frontend/src/components/VirtualPianoKeyboard.tsx` -- `frontend/src/components/RegionMarkerManager.tsx` -- `frontend/src/components/ClipPropertiesPanel.tsx` -- `frontend/src/components/BigClock.tsx` -- `frontend/src/components/RenderQueuePanel.tsx` -- `frontend/src/components/RoutingMatrix.tsx` -- `frontend/src/components/MediaExplorer.tsx` - -### `Design screenshots/Insert-options.png` - -Status: `Partial` - -Already implemented: - -- Media file import -- New MIDI item equivalent via empty MIDI clip -- Empty item -- Marker -- Marker with prompt for name -- Region from selection -- Track insertion -- Multiple tracks -- Virtual instrument on new track - -Partial or different from screenshot: - -- The current Insert menu includes more OpenStudio-specific track creation choices like bus/group tracks and spacer tracks. -- Screenshot entries like SMPTE timecode generator and click source are not surfaced. -- `Track from template` exists conceptually through templates, but top-level parity is incomplete. - -Relevant implementation: - -- `frontend/src/components/MenuBar.tsx` -- `frontend/src/store/actionRegistry.ts` - -### `Design screenshots/Item-options.png` - -Status: `Missing` as a top-level menu, `Partial` as underlying behavior - -Already implemented in behavior: - -- Select all -- Nudge/set items equivalent via nudge actions -- Split items at cursor -- Split items at time selection -- Item properties equivalent through clip properties -- Group / ungroup selected clips -- Reverse clip -- Item-level editing functions in timeline context menus and store actions - -Still missing for screenshot parity: - -- No top-level `Item` menu is rendered today. -- Existing item-related behavior is split across: - - `Edit` menu - - clip context menus - - timeline interactions - - clip properties panel -- Screenshot-level grouping such as `Take`, `Comps`, `Stretch markers`, `Spectral edits`, and `Media` is not exposed as a dedicated top menu structure. - -Relevant implementation: - -- `frontend/src/components/menus/EditMenu.tsx` -- `frontend/src/components/ClipPropertiesPanel.tsx` -- `frontend/src/components/Timeline.tsx` -- `frontend/src/store/actionRegistry.ts` -- `frontend/src/store/actions/clipEditing.ts` - -### `Design screenshots/Track-options.png` - -Status: `Missing` as a top-level menu, `Partial` as underlying behavior - -Already implemented in behavior: - -- Insert new track -- Insert multiple tracks -- Insert virtual instrument track -- Save/load track templates -- Remove tracks -- Duplicate tracks -- Move tracks to folder -- Freeze / unfreeze -- Track color -- Track icon -- Free item positioning -- Track automation-related operations -- Routing-related operations - -Current mismatch: - -- No top-level `Track` menu is rendered today. -- A large portion of screenshot-relevant behavior lives in track context menus instead of the main menu bar. -- Track-level commands are discoverable only after right-clicking track headers, which is not screenshot parity. - -Still missing or not clearly surfaced: - -- Dedicated `Meters` submenu parity -- `Track timebase` -- `Track performance options` -- `Track layout` -- `Track grouping` as top-level menu content -- `MIDI track controls` and `Lock track controls` as explicit grouped menu content - -Relevant implementation: - -- `frontend/src/components/SortableTrackHeader.tsx` -- `frontend/src/components/TrackHeader.tsx` -- `frontend/src/store/useDAWStore.ts` -- `frontend/src/store/actions/tracks.ts` - -### `Design screenshots/Options-options.png` - -Status: `Partial` - -Already implemented: - -- Record mode selection -- Ripple editing mode selection -- Auto-crossfade -- Locking -- Theme settings -- Preferences -- Timecode / sync settings - -Partially matching: - -- Screenshot-specific option naming does not line up perfectly with the current menu structure. -- Some screenshot entries are represented by broader or differently grouped OpenStudio settings. - -Still missing or not surfaced: - -- Many PRO DAW-specific editing preference toggles shown in the screenshot are not directly exposed. -- `Metronome/pre-roll settings...` is not surfaced in the Options menu as shown. -- Several transport-display and playback-scroll options are not top-menu surfaced in the same way. - -Relevant implementation: - -- `frontend/src/components/MenuBar.tsx` -- `frontend/src/components/PreferencesModal.tsx` -- `frontend/src/components/TimecodeSettingsPanel.tsx` - -### `Design screenshots/Actions-options.png` - -Status: `Missing` as a top-level menu, `Partial` in underlying system - -Already implemented: - -- A central action registry exists. -- Command palette exists. -- Recent actions behavior exists in the command palette. -- Shortcut metadata and action categories are already centralized. - -Still missing for screenshot parity: - -- No top-level `Actions` menu is rendered today. -- No menu-level recent action list matching the screenshot is exposed. -- No direct `Show action list...` top-menu destination exists yet, even though the action registry is already suitable for it. - -Relevant implementation: - -- `frontend/src/store/actionRegistry.ts` -- `frontend/src/components/CommandPalette.tsx` -- `frontend/src/components/MenuBar.tsx` - -### `Design screenshots/Help-options.png` - -Status: `Partial` - -Already implemented: - -- Getting started guide -- Help reference -- Keyboard shortcuts -- Check for updates -- Command palette -- About dialog - -Still missing or not aligned: - -- Screenshot-specific entries such as documentation submenu, project timebase help, action list as HTML, and legal/purchasing/changelog links are not exposed. -- The Help menu is more product-focused than screenshot-parity focused right now. - -Relevant implementation: - -- `frontend/src/components/MenuBar.tsx` -- `frontend/src/components/HelpOverlay.tsx` -- `frontend/src/components/KeyboardShortcutsModal.tsx` -- `frontend/src/components/GettingStartedGuide.tsx` - -### `Design screenshots/render-export.png` - -Status: `Partial` - -Already implemented: - -- Render dialog exists and is functional. -- Source selection -- Bounds selection -- Time bounds section -- Output directory -- File naming -- Format selection -- Sample rate -- Channels -- Bit depth where applicable -- MP3 bitrate -- OGG quality -- Normalize -- Dither -- Add to queue -- Multi-file render count logic -- Region-based render support -- Stem render support -- Secondary output support -- Add-to-project-after-render flow - -Known current gaps: - -- Metadata section is present but explicitly marked `coming soon`. -- Resample quality is shown but explicitly marked `Backend support pending`. -- Online render is shown but explicitly marked `unsupported`. -- Screenshot-style preset handling is not fully matched. -- Screenshot-specific fine-grained embed / preserve / dithering / second-pass combinations are not fully mirrored yet. - -Status classification details: - -- `Metadata`: `Backend-dependent` -- `Resample quality`: `Backend-dependent` -- `Online render`: `Backend-dependent` -- Overall dialog: `Partial` - -Relevant implementation: - -- `frontend/src/components/RenderModal.tsx` -- `frontend/src/components/RenderQueuePanel.tsx` -- `frontend/src/services/NativeBridge.ts` -- `Source/AudioEngine.cpp` -- `Source/MainComponent.cpp` - -### `Design screenshots/midi track and clip.png` - -Status: `Partial` - -Already implemented: - -- MIDI track support -- Piano roll editor -- Virtual MIDI keyboard -- Instrument tracks -- MIDI device routing - -What still differs: - -- The screenshot is focused on arrange-view MIDI clip visibility inside the main timeline. -- The current app has the necessary MIDI infrastructure, but screenshot-level visual parity for the exact arrange-view presentation needs a dedicated audit pass against timeline rendering and track header MIDI affordances. - -Relevant implementation: - -- `frontend/src/components/PianoRoll.tsx` -- `frontend/src/components/VirtualPianoKeyboard.tsx` -- `frontend/src/components/Timeline.tsx` -- `frontend/src/components/TrackHeader.tsx` - -## Consolidated Status - -### Already Implemented - -- Main DAW shell -- Project tabs -- Menu bar framework -- File menu core actions -- Edit menu core actions -- View menu major panels and toggles -- Insert menu core track and marker actions -- Track header controls -- Timeline -- Mixer -- Transport -- Render queue -- Project settings dialog -- Region render matrix dialog -- Dynamic split dialog -- Clean project directory dialog -- Batch file converter dialog -- Crossfade editor dialog -- Undo history panel -- Command palette -- Clip properties panel -- Piano roll -- Virtual MIDI keyboard - -### Partial - -- Overall screenshot shell parity -- File menu parity -- Edit menu parity -- View menu parity -- Insert menu parity -- Options menu parity -- Help menu parity -- Render/export parity -- MIDI arrange-view parity -- Track header visual parity - -### Missing - -- Top-level `Item` menu -- Top-level `Track` menu -- Top-level `Actions` menu -- Screenshot-matching menu grouping for many existing item and track actions -- Screenshot-level help/documentation menu structure - -### Backend-dependent - -- Render metadata -- Full resample quality support -- Online render mode -- Any additional screenshot-visible render options that require new bridge parameters - -## Remaining Work TODO - -### 1. Create the dedicated deliverable - -- [x] Create `docs/design-screenshot-gap-review.md` as the dedicated audit and remaining-work plan. -- [x] Compare each supplied screenshot against the current app shell, menus, dialogs, and track/timeline UI. -- [x] Mark each screenshot-visible feature as `implemented`, `partial`, `missing`, or `backend-dependent`. -- [x] Keep the scope focused on screenshot parity rather than full PRO DAW parity. - -### 2. Finish menu-bar parity - -- [ ] Add top-level `Item` menu to the menu bar. -- [ ] Add top-level `Track` menu to the menu bar. -- [ ] Add top-level `Actions` menu to the menu bar. -- [ ] Keep menu order aligned to the screenshots: - - `File` - - `Edit` - - `View` - - `Insert` - - `Item` - - `Track` - - `Options` - - `Actions` - - `Help` -- [ ] Normalize labels, separators, and submenu grouping to match the screenshots more closely. -- [ ] Normalize shortcut display where the action registry already defines the binding. -- [ ] Use disabled entries only when screenshot visibility matters and the feature is not yet implemented. - -### 3. Refactor menu definitions - -- [ ] Refactor menu definitions so menu items come from shared builders instead of duplicated inline logic in `MenuBar.tsx`. -- [ ] Reuse existing store actions and action-registry definitions where possible. -- [ ] Avoid duplicating behavior between: - - `EditMenu` - - track context menus - - action registry - - top-level menu definitions -- [ ] Centralize label, enablement, checked state, and shortcut resolution. - -### 4. Promote already-implemented item behavior into a real `Item` menu - -- [ ] Move clip/item-oriented commands out of the overloaded `Edit` menu where needed. -- [ ] Include currently implemented actions such as: - - split at cursor - - split at time selection - - nudge operations - - group / ungroup - - reverse clip - - dynamic split - - crossfade editor entry point - - clip/item properties -- [ ] Add placeholder or disabled sections only where screenshot-level grouping is needed but behavior is not yet present. - -### 5. Promote track behavior into a real `Track` menu - -- [ ] Surface track operations that already exist in track context menus and store actions. -- [ ] Include currently implemented actions such as: - - insert new track - - insert multiple tracks - - virtual instrument track - - delete track(s) - - duplicate track - - move to folder - - save/load track template - - freeze / unfreeze - - track color - - track icon - - free item positioning - - routing-related entry points - - automation-related entry points -- [ ] Add screenshot-driven structure for track grouping and track controls without removing OpenStudio-specific affordances. - -### 6. Build the `Actions` menu from the existing action system - -- [ ] Back the `Actions` menu with `frontend/src/store/actionRegistry.ts`. -- [ ] Add a top-level entry equivalent to `Show action list...`. -- [ ] Surface recent actions using the same recent-action source already used by `CommandPalette`. -- [ ] Keep action labels and shortcuts synchronized with the registry rather than copying strings into the menu. - -### 7. Tighten File / View / Insert / Options / Help parity - -- [ ] Add missing File-menu screenshot entries where a reasonable OpenStudio equivalent already exists. -- [ ] Align `Render Queue` / `Queued Renders` naming and placement decisions. -- [ ] Decide whether `Capture Output` should be renamed or regrouped to match screenshot expectations better. -- [ ] Expand View menu coverage for screenshot-visible managers and utility windows. -- [ ] Expand Insert menu coverage for screenshot-visible non-track insert tools where appropriate. -- [ ] Expose metronome/pre-roll and related transport options if they are needed for screenshot parity. -- [ ] Expand Help menu structure to cover screenshot-visible documentation and reference entry points. - -### 8. Expand render/export parity carefully - -- [ ] Keep the current working render modal intact as the base. -- [ ] Finish screenshot-visible preset handling if required for parity. -- [ ] Implement render metadata behavior or keep it clearly marked as backend-dependent. -- [ ] Implement resample quality behavior or keep it clearly marked as backend-dependent. -- [ ] Implement online render mode or keep it clearly marked as backend-dependent. -- [ ] Review screenshot-visible checkbox groups and labels for naming and placement parity. -- [ ] Preserve backward compatibility for existing bridge calls while extending render options. - -### 9. Document backend dependencies separately - -- [ ] Track backend-required parity items in their own section, separate from frontend-only tasks. -- [ ] Include bridge/API implications for each backend-dependent item. -- [ ] Keep the frontend plan executable even if backend tasks are staged later. - -## Test Plan TODO - -- [ ] Add tests that assert full top-level menu order and presence. -- [ ] Add tests for representative `Item` menu commands. -- [ ] Add tests for representative `Track` menu commands. -- [ ] Add tests for representative `Actions` menu commands. -- [ ] Add render-modal tests for visible supported vs unsupported states. -- [ ] Add tests for any shared menu-builder logic introduced during refactor. -- [ ] Run `npm test` in `frontend`. -- [ ] Run `npm run build` in `frontend`. -- [ ] Perform manual screenshot-by-screenshot parity verification after implementation. - -## Assumptions - -- [x] Treat the supplied screenshots as the design source of truth. -- [x] Target design plus visible behavior parity, not visual-only parity. -- [x] Use the current React, Zustand, and modal architecture; do not replace it with native menus. -- [x] Preserve backward compatibility for existing store actions and bridge calls where possible. -- [x] Preserve existing OpenStudio-specific features unless they directly conflict with screenshot parity. - -## Priority Order - -Recommended implementation order: - -1. Add `Item`, `Track`, and `Actions` top-level menus. -2. Refactor menu definitions into shared builders. -3. Promote existing item and track operations into screenshot-aligned menu structures. -4. Normalize File / View / Insert / Options / Help labels and grouping. -5. Expand render/export parity. -6. Add menu and render tests. -7. Perform final manual screenshot parity pass. diff --git a/docs/engine_v3_fullclip_prototype_20260417.md b/docs/engine_v3_fullclip_prototype_20260417.md deleted file mode 100644 index 972c87b..0000000 --- a/docs/engine_v3_fullclip_prototype_20260417.md +++ /dev/null @@ -1,97 +0,0 @@ -# Engine-V3 Full-Clip Prototype Status (2026-04-17) - -## What Landed -- Added new renderer branches: - - `engine_v3_fullclip` - - `engine_v3_fullclip_lpc` - - `engine_v3_fullclip_lpc_transient` -- Added engine-v3 diagnostics plumbing through native + frontend regression results. -- Added continuous full-clip HQ output path for engine-v3 in `AudioEngine`. -- Added first LPC spectral envelope transfer helper in `LpcEnvelopeTransfer`. -- Hardened the current Signalsmith pitch-only path for better baseline formant configuration: - - `setFormantBase(avgDetectedHz)` per block - - inverse-ratio formant factor by default - -## Important Bug Fixed During Bring-Up -- The first engine-v3 full-clip implementation created a corrected full buffer and then overwrote it with the original `clipBuffer` before writing the output file. -- This made the branch look active in diagnostics while exporting source-identical audio. -- That overwrite bug is now fixed. - -## Current Truth -- `engine_v3_fullclip` now produces a real full-clip processed output. -- `engine_v3_fullclip_lpc` no longer collapses into NaN/silence after stability hardening, but it is still not keepable. -- The current engine-v3 prototype does **not** yet beat `pitch_only_adaptive_selector` on the canonical `pitchOrg +4` truth case. - -## First Canonical Results -### Plain continuous carrier -- Run: - - `D:\test projects\os tests\runs\20260417_035915_engine_v3_pitchOrg_plus4_fullclip_plain_r2fresh` -- Summary: - - `engine_v3_fullclip` - - sane audio output - - note mel `9.963` - - note envelope `1.229` - - entry mel `7.867` - - exit mel `9.306` - - onset artifact `2.43` -- Verdict: - - proves the continuous full-clip carrier path is live - - still substantially worse than the kept adaptive baseline on the target problem - -### LPC formant pass -- Run: - - `D:\test projects\os tests\runs\20260417_035915_engine_v3_pitchOrg_plus4_fullclip_lpc_r3fresh` -- Summary: - - `engine_v3_fullclip_lpc` - - numerically stable after guard fixes - - still extremely poor perceptual/regression quality - - note mel `48.943` - - note envelope `6.362` - - entry mel `49.398` - - exit mel `48.759` -- Verdict: - - current LPC transfer implementation is not viable yet - - it remains prototype-only and must not be promoted - -## What This Means -- The structural continuity change is now real and benchmarkable. -- The first formant-transfer implementation is still wrong for the target vocal edit quality. -- Engine-v3 remains a prototype branch, not a product-ready replacement. - -## Boundary-Zone Follow-Up -- Added a true boundary-zone blend on top of the continuous carrier: - - original shell at the boundary - - own-engine first-voiced-cycles patch - - continuous carrier for the stable body -- Canonical runs: - - `D:\test projects\os tests\runs\20260417_040833_engine_v3_pitchOrg_plus4_fullclip_plain_r3boundary` - - `D:\test projects\os tests\runs\20260417_040833_engine_v3_pitchTest_plus4_fullclip_plain_r1boundary` -- Diagnostics confirm the boundary slice is engaging: - - `firstVoicedCyclesEntryUsed=true` - - `firstVoicedCyclesExitUsed=true` - - `v3ContinuousRenderUsed=true` -- Verdict: - - this is the first real boundary-zone ownership prototype - - it still does not clear the quality gate on the canonical `+4` cases - -## Safer Formant/Body Harvest Follow-Up -- Added a low-wet own-engine body-color harvest inside the edited note body only. -- Canonical runs: - - `D:\test projects\os tests\runs\20260417_101035_engine_v3_pitchOrg_plus4_fullclip_plain_r4bodyharvest` - - `D:\test projects\os tests\runs\20260417_101035_engine_v3_pitchTest_plus4_fullclip_r2bodyharvest` -- Verdict: - - this bounded formant/body-color attempt did not materially move the canonical truth metrics - - it is not the missing win - -## Immediate Next Work -1. Keep `pitch_only_adaptive_selector` as the shipping baseline. -2. Treat `engine_v3_fullclip` as the active engine-v3 prototype. -3. Keep `engine_v3_fullclip_lpc` and `_transient` as experimental-only until they beat the baseline on: - - `pitchOrg +4` - - `pitchTestOrg +4` - - `pitchOrg -4` - - `pitchTestOrg -4` -4. Next engine-v3 DSP work should focus on: - - first-voiced-cycles boundary ownership - - safer formant transfer - - immediate-neighbor-only smoothing validation diff --git a/docs/features.md b/docs/features.md deleted file mode 100644 index fae8fbc..0000000 --- a/docs/features.md +++ /dev/null @@ -1,347 +0,0 @@ -# OpenStudio / Studio13-v3 Feature Inventory - -> **Last updated:** 2026-06-01 -> -> **Primary project format:** `.osproj` -> -> **Legacy project format:** `.s13` -> -> **Scope of this update:** current source tree, mounted UI, action registry, frontend bridge, backend native functions, and adjacent feature docs. -> -> **Note:** This file used to be a historical REAPER-parity tracker with stale counts. It is now a current feature inventory. Keep older planning docs for traceability, but use this file, `README.md`, and `docs/implemented_features.md` for public-facing feature claims. - -## Status Legend - -| Status | Meaning | -|---|---| -| Implemented | User-facing workflow has a UI/action and a store, bridge, or backend path. | -| Partial / Experimental | Real code exists, but the workflow is incomplete, hardware/runtime dependent, or not yet release-hardened. | -| Planned / Stub | UI state, bridge stubs, or planning notes exist, but the feature should not be advertised as complete. | - -## Latest Additions Since The Old Tracker - -| Area | Latest feature surface | -|---|---| -| AI runtime setup | In-app AI Tools setup with install, cancel, reset, status refresh, feature selection, background install notification, and platform-specific runtime plans. | -| AI music generation | ACE-Step 1.5 XL Turbo workflows for text-to-music and lyrics-plus-style generation. | -| AI audio generation | Stable Audio 3 Medium support for text-to-audio and source-conditioned workflows, including license-gated local model import. | -| Clip AI workflows | Create variation, inpaint selected range, and continue clip from a selected source audio clip. | -| Stem separation | Async stem separation with progress polling, cancellation, selectable stems, and result import to tracks. | -| Audio-to-MIDI | Convert an audio clip to a new MIDI track using polyphonic note extraction where the native Basic Pitch / ONNX path is available. | -| Detached editors | Detached mixer and detached MIDI editor window flows with UI snapshot publishing through the native bridge. | -| MIDI editor depth | Docked or windowed piano roll sessions, velocity/CC lanes, pitch bend controls, quantize, note transforms, and range editing. | -| Render/export | Dither path, secondary output, render queue, region render matrix, DDP export, batch converter, render-in-place, and add-render-to-project options. | -| Routing and mixing | Send/bus routing, routing matrix, sidechain assignment, channel output selection, stereo width, phase invert, pan law, and mixer snapshots. | -| Project delivery | Project compare, archive/unarchive, clean project directory, safe-mode open, project templates, and project tabs. | -| Video and sync | Video window plumbing, FFmpeg-backed video/audio extraction path, MTC input/output, MIDI clock sync state, and timecode settings. | -| Scripting and effects | Lua script editor/API, S13FX/JSFX audio effects, JSFX `@gfx` editor support, plugin A/B states, FX chain presets, and built-in FX presets. | -| Workflow customization | Command palette, shortcuts modal, screensets, theme editor, toolbar editor, custom actions/macros, mouse modifiers, and help surfaces. | - -## At A Glance - -| Area | Current support | -|---|---| -| Core engine | JUCE C++ audio engine with React/TypeScript WebView UI and synchronous `window.__JUCE__.backend` bridge. | -| Recording | Multitrack audio recording, MIDI recording, punch range, record modes, armed tracks, record-safe state, and input monitoring. | -| Editing | Clip move/trim/split, ripple edit, razor edit, time selection edits, fades, grouping, takes, slip edit, reverse, normalize, time stretch, and pitch shift. | -| MIDI | MIDI and instrument tracks, piano roll, virtual keyboard, MIDI import/export, MIDI output routing, quantize, transforms, CC lanes, and audio-to-MIDI. | -| Mixing | Mixer, detached mixer, sends, buses, routing matrix, master strip, channel strip EQ, metering, automation, mixer snapshots, and sidechains. | -| Plugins | VST3 hosting, CLAP/LV2 code paths, native editors, input/track/master/monitoring FX, presets, A/B states, MIDI learn, and built-in FX. | -| Pitch | Graphical pitch editor, YIN pitch analysis, note blobs, drift/vibrato tools, real-time pitch corrector, polyphonic detection, and ARA host plumbing. | -| AI | Optional local AI Tools runtime for stem separation, ACE-Step music generation, Stable Audio 3 audio generation, variation, inpaint, and continuation. | -| Render | WAV, AIFF, FLAC, MP3, OGG, stems, selected items, razor areas, regions, render queue, region matrix, DDP, batch conversion, dither, metadata, and secondary output. | -| Project tools | `.osproj` save/load, `.s13` legacy support, recent projects, templates, safe mode, autosave/backup, compare, archive, clean directory, missing media resolver. | -| Workflow | Command palette, keyboard shortcuts, menus, screensets, themes, toolbar editor, help overlay, getting started guide, big clock, and timecode settings. | -| Sync/media | MTC/MIDI clock plumbing, control surface manager, OSC support, MCU-style control surface paths, video window, and FFmpeg video extraction. | - -## Core Engine, Transport, And Recording - -### Implemented - -- Native JUCE audio engine with a React/TypeScript UI hosted in WebView2. -- Audio device settings for driver, device, input/output channels, sample rate, buffer size, and channel configuration. -- Sample-rate-aware clip playback and render mixing through the playback engine. -- Transport controls for play, pause, stop, record, seek, rewind, loop, set loop to time selection, and auto-scroll. -- Tempo, tap tempo, time signature, tempo markers, and metronome controls. -- Metronome accenting, volume, custom click/accent sounds, reset sounds, and render-to-file support. -- Multitrack audio recording with track arm, record-safe, input channel selection, monitoring, punch range, and record modes. -- MIDI recording with live preview and completed MIDI clip handoff. -- Background waveform peak cache and recording waveform previews. -- Real-time meter update event flow isolated from track arrays for render performance. - -### Partial / Experimental - -- External sync paths exist for MTC and MIDI clock, but device and studio integration still needs hardware validation. -- LTC output has a UI/bridge surface, but the backend explicitly treats generation as a stub. -- Live capture output has bridge/store state and should be treated as experimental until fully validated in-session. - -## Project, Files, And Media Management - -### Implemented - -- New, open, save, save as, close project, quit, and unsaved-changes confirmation flows. -- `.osproj` as the primary project extension with `.s13` legacy open/save support. -- Recent projects, startup recovery/diagnostics, and safe-mode project open with FX bypass. -- Project settings for name, notes, sample rate, bit depth, tempo, time signature, author/revision style metadata, and related state. -- Timestamped backup/autosave preferences. -- Project tabs, project templates, save-as-template, and new-from-template flows. -- Project compare with saved version. -- Session archive/unarchive backend paths. -- Media import through native dialogs, drag/drop, and audio/video import path. -- Missing media resolver. -- Media explorer surface, recent paths, and media import workflow. -- Clean project directory tool. -- Batch file converter modal. - -### Partial / Experimental - -- Media explorer audition/preview should be treated as limited until the backend preview engine is expanded. -- AAF/session interchange code paths exist, but AAF import is not a complete advertised workflow. - -## Arrangement And Clip Editing - -### Implemented - -- Konva timeline with ruler, grid, snap, zoom, scroll, playhead, waveform rendering, MIDI thumbnails, and time selection. -- Audio and MIDI clip creation, media import, drag/drop, move, resize, trim, and track-to-track moves. -- Multi-clip and multi-track selection, select all clips, select all tracks, and deselect all. -- Undo/redo through the command manager for editing operations. -- Cut, copy, paste, duplicate, delete, nudge, and fine nudge. -- Split at cursor and split at time selection. -- Cut/copy/delete within time selection and insert silence. -- Ripple editing modes: off, per-track, and all-tracks. -- Razor edit areas with content deletion and render source support. -- Clip mute, lock, color, volume, pan, fade in/out, fade shape, gain envelope, and clip properties panel. -- Auto-crossfade and a crossfade editor surface. -- Reverse clip, normalize selected clips, time stretch, and clip pitch shift. -- Dynamic split/transient split UI and execution path. -- Slip editing and free item positioning. -- Takes: add take, set active take, explode takes to tracks, and implode clips into takes. -- Track spacers, empty items, empty MIDI items, insert multiple tracks, and folder tracks. -- Clip launcher view. -- Quantize selected clips to grid. - -## MIDI And Instruments - -### Implemented - -- MIDI device enumeration, input opening, output routing, and MIDI panic. -- MIDI tracks and instrument tracks. -- Virtual instrument on new track and quick-add instrument track actions. -- MIDI clip storage, serialization, playback scheduling, import, export, and project MIDI export. -- Piano roll editor with docked and detached/windowed sessions. -- Note draw, edit, select, cut/copy/paste, delete, resize, move, and range operations. -- Velocity editing, CC lanes, pitch bend controls, visible lane preferences, and note inspector style surfaces. -- MIDI quantize using last settings, reset quantize, freeze quantize, and quantize test coverage. -- MIDI transforms: transpose, octave transpose, velocity scale, reverse notes, invert note pitches, and snap selected notes to scale. -- Virtual piano keyboard. -- MIDI input readiness checks before recording. -- Audio-to-MIDI conversion creates a generated MIDI track beside the source track, with undo support. -- Built-in instrument paths for basic synth, piano, drums, and sampler-style state. - -### Partial / Experimental - -- Drum editor and media pool toggles/actions exist, but should be confirmed as mounted, complete workflows before being advertised heavily. -- Step sequencer/step input state exists in the store surface, but the full user workflow needs validation. - -## Mixing, Routing, Metering, And Automation - -### Implemented - -- Mixer panel, channel strips, master strip, master track in TCP, and detached mixer window. -- Track controls for volume, pan, mute, solo, arm, input monitoring, record-safe, channel count, playback offset, and output channels. -- Master volume, pan, mute, mono, and master automation state. -- Peak/RMS meters, master meter cluster, clipping state, and reset meter clip actions. -- Channel strip EQ modal and built-in channel EQ backend parameters. -- Sends with level, pan, enable, pre/post fader, and phase invert. -- Bus/group tracks and create-bus-from-selected-tracks. -- Routing matrix and track routing modal. -- Sidechain source assignment into plugins. -- Phase invert, stereo width, master send enable, pan law, and DC offset handling. -- Track groups / linked group parameters. -- Mixer snapshots with save, recall, delete, backend sync, and undo on recall. -- Track and master automation lanes with read, write, touch, latch, manual point editing, range replace, backend sync, and envelope manager. -- Move-envelopes-with-items option. -- Loudness meter, phase correlation, and spectrum data bridge paths. - -## Plugins, FX, And Scripting - -### Implemented - -- Plugin scanning and loading for VST3, with CLAP/LV2 code paths present. -- Native plugin editor window management. -- Input FX, track FX, master FX, and monitoring FX chains. -- Add, remove, bypass, reorder, and open editor flows for FX chains. -- Plugin parameters, preset load/save, state save/load, and plugin A/B compare. -- Plugin MIDI learn, mapping list, and clear mapping flows. -- FX chain presets for track, input, and master chains. -- Built-in EQ, compressor, gate, limiter, delay, reverb, chorus, saturator, pitch corrector, and instrument-style processors. -- Built-in FX preset save/load/delete. -- Built-in FX oversampling controls. -- S13FX / JSFX-style script effects. -- JSFX `@gfx` native editor support through `S13FXGfxEditor`. -- Lua script execution, script directory/listing, script editor UI, and console output. -- App-facing Lua API documentation in `docs/API.md`. - -### Partial / Experimental - -- CLAP and LV2 support should be described as code-path/plugin-format support until compatibility is validated with a plugin test matrix. -- 32-bit plugin bridge is currently a preference/toggle surface, not a completed out-of-process legacy plugin host. - -## Pitch, Analysis, And Audio Processing - -### Implemented - -- Monophonic pitch analysis with YIN contour and note segmentation. -- Graphical pitch editor with note blobs, pitch contour, piano grid, zoom, scroll, and lower-zone UI. -- Pitch editor tools for pitch, drift, vibrato, transition, draw/split style editing, selection, undo, and redo. -- Scale/key snapping, chromatic snap, correct-pitch macro, and scale detection support. -- Offline monophonic graphical pitch correction/apply path. -- Pitch preview, scrub preview, rendered preview segments, and route/status diagnostics. -- Real-time auto-tune style pitch corrector FX. -- Polyphonic detection and audio-to-MIDI extraction through the Basic Pitch / ONNX path where available. -- Audio analyzer, transient detection, and silent-region style analysis utilities. -- ARA host controller lifecycle and track/clip ARA status plumbing. - -### Partial / Experimental - -- Polyphonic pitch correction/resynthesis is less mature than monophonic pitch editing. Treat detection and MIDI extraction as stronger than polyphonic audio correction. -- Subjective pitch, formant, stem, and artifact quality always requires user audition. Harness diagnostics are not proof of audible quality. - -## AI And Assisted Audio - -### Implemented - -- Optional AI Tools runtime status with refresh, install, cancel, reset, selected feature install, requested feature routing, and setup progress. -- Modular AI Tools feature IDs for `stemSeparation` and `audioGeneration`. -- Hardware/runtime status including GPU support hints and model/runtime readiness. -- Platform runtime packaging plans for Windows DirectML/CUDA and Linux CUDA/ROCm style profiles. -- Stem separation workflow with selectable stems, progress polling, cancellation, and imported result clips/tracks. -- AI track type and AI track header controls. -- AI generation modal and AI workflow parameter fields. -- ACE-Step 1.5 XL Turbo model surface. -- Stable Audio 3 Medium model surface. -- AI workflows: - - Text to Music - - Lyrics + Style - - Text to Audio - - Create Variation - - Inpaint Selection - - Continue Clip -- Source-conditioned AI generation from selected clips, with time-selection requirements for inpaint. -- AI generation progress and cancellation. -- Stable Audio setup includes local snapshot selection and license acknowledgement state. - -### Partial / Experimental - -- AI generation quality and speed depend on installed optional runtimes, local hardware, available VRAM/RAM, model availability, and accepted model licenses. -- The core app intentionally does not bundle large AI model runtimes. - -## Render, Export, And Delivery - -### Implemented - -- Offline render through the same playback/FX path used by playback. -- Render sources for master, stems, selected tracks, selected media items, selected items through master, and razor edit areas. -- Region render matrix UI. -- Render bounds for entire project, custom range, time selection, project regions, and selected regions where the UI path is available. -- Output directory, filename, and wildcard filename resolution. -- Metadata state for rendered files. -- Formats: WAV, AIFF, FLAC, MP3, and OGG. -- Sample-rate conversion and lossy encoding through FFmpeg where needed. -- Mono/stereo output, bit depth/quality options, normalize, render tail, and include-metronome options. -- Dithered render path through `renderProjectWithDither`. -- Secondary output format and secondary bit depth. -- Online render and add-rendered-output-to-project UI state. -- Render queue panel and queue actions. -- Render clip in place and render track in place. -- Consolidate track. -- Freeze/unfreeze track state and related UI. -- DDP disc image export backend and modal. -- Batch converter modal. -- Export project MIDI. - -### Partial / Experimental - -- Region render matrix, DDP, batch conversion, online render, and add-to-project workflows should receive release smoke testing before high-confidence public release notes. -- Advanced broadcast metadata, immersive multichannel delivery, and full CD mastering validation remain specialist areas to test separately. - -## Workflow, UI, And Customization - -### Implemented - -- Central action registry used by menus, command palette, shortcut reference, and global shortcuts. -- Menu bar, main toolbar, transport bar, lower zone, mixer, timeline, and modal surfaces. -- Command palette. -- Keyboard shortcuts modal. -- Preferences modal with general, editing, display, and backup preferences. -- Help overlay and getting started guide. -- Big clock and timecode settings. -- Screensets/layout state with save/load actions. -- Theme presets, theme editor, custom theme overrides, theme import/export style state, and high-contrast support. -- Toolbar editor and custom toolbar state. -- Custom actions/macros. -- Mouse modifier preferences and reset. -- Toast notifications, modal safety guards, startup recovery app, and error boundary. -- Detachable panels for mixer and MIDI editor. - -## Sync, Control Surfaces, Video, And Pro-Audio Plumbing - -### Implemented / Present - -- Timecode display and timecode settings panel. -- MTC generator and receiver paths. -- MIDI clock sync source/status plumbing. -- MIDI control surface manager. -- OSC control surface support. -- MCU-style control surface support. -- Video window component. -- FFmpeg-backed video metadata/frame extraction and audio extraction path. -- Surround/channel layout and VBAP panner source modules. -- Track channel format and master channel format state. - -### Partial / Experimental - -- Video support is functional plumbing, not a full post-production video suite. -- External sync and control surface workflows should be tested with real devices. -- Surround/immersive workflows need dedicated validation before being advertised as mature. - -## File Types And Sidecar Assets - -| Type | Current role | -|---|---| -| `.osproj` | Primary project file. | -| `.s13` | Legacy project file support. | -| `.ostheme` | Current theme export/import target. | -| `.s13theme` | Legacy theme import support. | -| `.ospreset` | Built-in FX preset style. | -| `.ospeaks` | Current waveform peak cache sidecar. | -| `.s13peaks` | Legacy waveform peak cache sidecar support. | -| `.mid` / `.midi` | MIDI import/export. | -| `.wav`, `.aiff`, `.flac`, `.mp3`, `.ogg` | Render/export and media workflows. | - -## Current Caveats To Keep Honest - -| Feature | Caveat | -|---|---| -| Polyphonic pitch correction | Detection and MIDI extraction are present; polyphonic audio correction/resynthesis is still less mature. | -| CLAP/LV2 hosting | Code paths exist; plugin compatibility still needs broad validation. | -| 32-bit plugin bridge | Toggle/state exists; full legacy plugin bridge is not complete. | -| LTC output | Bridge surface exists; backend generation is stubbed. | -| Media explorer preview | Import/browse surface exists; audition preview is limited. | -| AAF import/interchange | Source code exists, but it should not be advertised as complete AAF support. | -| Video | FFmpeg-backed video plumbing exists; editing/post-production workflow maturity is still evolving. | -| AI generation | Optional runtime and hardware dependent; model licenses and local setup affect availability. | -| Audio quality claims | Pitch, formant, stem, and generation quality must be validated by listening, not just diagnostics. | - -## Source Cross-Reference - -- `README.md` - public overview and product positioning. -- `docs/implemented_features.md` - codebase feature audit and caveats. -- `frontend/src/store/actionRegistry.ts` - commands exposed to menus, shortcuts, and command palette. -- `frontend/src/App.tsx` - mounted panels, modals, lower zones, detached windows, and workflow surfaces. -- `frontend/src/services/NativeBridge.ts` - frontend-to-native feature boundary. -- `frontend/src/data/aiWorkflows.ts` - AI models, workflows, and parameters. -- `frontend/src/store/actions/*` - current Zustand action modules. -- `Source/MainComponent.cpp` - native bridge functions exposed to the frontend. -- `Source/AudioEngine.*`, `Source/PlaybackEngine.*`, `Source/TrackProcessor.*`, `Source/MIDIManager.*`, `Source/Pitch*`, `Source/StemSeparator.*`, `Source/AITrackEngine.*`, and related source files - backend feature implementation. diff --git a/docs/features.pdf b/docs/features.pdf deleted file mode 100644 index 938a748..0000000 Binary files a/docs/features.pdf and /dev/null differ diff --git a/docs/implemented_features.md b/docs/implemented_features.md index 808be4a..2274b00 100644 --- a/docs/implemented_features.md +++ b/docs/implemented_features.md @@ -4,6 +4,12 @@ This audit treats the codebase as the source of truth: `CMakeLists.txt`, `Source Features are sorted by impact first, then complexity. +Inventory status rule: a feature belongs in the main tables only when a +user-facing workflow is mounted and has the necessary state, bridge, or backend +path. A real but incomplete, hardware-dependent, or code-path-only surface +belongs under [Implemented But Partial / Caveated](#implemented-but-partial--caveated) +and must not be advertised as a complete workflow. + Ratings: - `H`: High @@ -18,11 +24,13 @@ Ratings: | Real-time playback engine with sample-rate-aware clip mixing | H | H | | Audio device setup: driver, I/O, sample rate, buffer, channels | H | H | | Multitrack audio recording with armed tracks, monitoring, punch range | H | H | +| Record modes, record-safe state, input-channel selection, and set-loop-to-time-selection workflow | H | M | | Transport: play, stop, pause, record, seek, loop, current time | H | M | | Tempo, time signature, tap tempo, tempo markers | H | M | -| Metronome with accenting, volume, custom sounds, render-to-track | M | M | +| Metronome with accenting, volume, custom/reset click sounds, and render inclusion | M | M | | Background waveform peak cache and recording waveform previews | H | H | | MIDI recording preview and completed MIDI clip handoff | H | M | +| Meter events isolated from the track array to avoid playback-time UI churn | M | M | ## Arrangement / Editing @@ -38,7 +46,9 @@ Ratings: | Clip fades, clip volume, gain envelopes, mute, lock, color | H | M | | Clip reverse, normalize, time stretch, pitch shift | H | H | | Auto-crossfade and crossfade editor | H | M | -| Takes: explode, implode, active-take style state | M | M | +| Takes: add, select active, explode to tracks, and implode clips | M | M | +| Dynamic/transient split workflow | M | H | +| Track spacers, empty audio/MIDI items, and clip launcher | M | M | | Markers, named markers, regions, region manager | H | M | | Tempo marker support | H | M | | Quantize selected clips | M | M | @@ -60,6 +70,9 @@ Ratings: | Output channel selection, track channel count, playback offset | M | M | | LUFS measurement, phase correlation, spectrum data | M | H | | Channel strip EQ modal | M | M | +| Mixer snapshots with save, recall, delete, backend sync, and undoable recall | M | H | +| Track/master automation lanes with read, write, touch, latch, range replacement, and envelope management | H | H | +| Move-envelopes-with-items option | M | M | ## Plugins / FX / Scripting @@ -71,14 +84,19 @@ Ratings: | Add, remove, bypass, reorder FX chains | H | M | | Plugin parameters, presets, state save/load, A/B compare | H | H | | Plugin MIDI learn and parameter mapping | H | H | +| Track, input, and master FX-chain presets | M | M | +| Built-in FX preset save, load, and delete | M | M | | Processing precision override / hybrid precision support | M | H | | Plugin capability matrix, guardrails, release benchmark hooks | M | H | | Built-in EQ, compressor, gate, limiter, delay, reverb, chorus, saturator | H | H | | Built-in real-time pitch corrector FX | H | H | | Built-in FX editors and oversampling controls | M | H | +| NAM Rack A1/A2 pedal, amp, and full-rig capture hosting | H | H | +| NAM Rack Guitar/Bass voicing, native pedalboard, cabinet IR/Cabinet Space, Graphic EQ, modulation, delay, reverb/shimmer, tuner, calibration, presets, A/B, and project recall | H | H | +| NAM Rack multi-capture pack selection with per-capture topology, transactional audition/rollback, Use, replace, bypass, unload, and missing-asset recovery | H | H | | S13FX / JSFX-style script effects with sliders and reload | H | H | | S13FX `@gfx` native editor support | M | H | -| Lua script execution, script listing, script editor UI | M | H | +| Lua script execution, script listing/editor, console output, and app-facing API reference | M | H | ## MIDI / Instruments @@ -89,12 +107,16 @@ Ratings: | MIDI clips with note storage and playback scheduling | H | H | | MIDI recording into clips with live preview | H | H | | Piano roll editor | H | H | -| MIDI note draw/edit/select, velocity, CC editing | H | H | +| Docked/detached piano-roll sessions with note, velocity, CC, pitch-bend, and range editing | H | H | | Virtual piano keyboard | M | M | -| Step sequencer and step input state/actions | M | M | -| MIDI transforms: transpose, velocity scale, reverse, invert | M | M | +| Piano-roll step input state/actions | M | M | +| MIDI panic and input-readiness checks | M | M | +| Quantize using last settings, reset quantize, and freeze quantize | M | M | +| MIDI transforms: transpose/octave, velocity scale, reverse, invert, and scale snap | M | M | | MIDI import/export and project MIDI export | H | M | | Load/open virtual instrument on instrument tracks | H | H | +| Built-in basic synth, piano, clean-guitar, drum, and fallback sampler paths | M | H | +| Audio-to-MIDI creates an adjacent MIDI track with undo support | H | H | ## Pitch / Audio Analysis @@ -109,7 +131,7 @@ Ratings: | Real-time auto-tune style pitch corrector plugin | H | H | | Pitch editor undo/redo and A/B style comparison state | M | M | | Transient detection and silent-region detection | M | M | -| Polyphonic pitch detection and MIDI extraction via Basic Pitch / ONNX | H | H | +| Polyphonic pitch detection and MIDI extraction via Basic Pitch / ONNX (ONNX-enabled builds; current Windows/Linux release pipeline) | H | H | | Stem-aware / AI-adjacent audio analysis plumbing | M | H | ## Rendering / Export / Interchange @@ -117,13 +139,15 @@ Ratings: | Feature | Impact | Complexity | |---|---:|---:| | Offline project render through the same playback/FX engine | H | H | -| Render formats: WAV, AIFF, FLAC, MP3, OGG | H | H | +| Render formats: WAV, AIFF, FLAC, MP3, OGG (FFmpeg-backed export uses the bundled audited binary on Windows and a system dependency on macOS/Linux) | H | H | | Render options: sample rate, bit depth/quality, mono/stereo, normalize, tail | H | M | | Dithered render path | M | H | -| Stem/track render code path and region render matrix UI | H | H | +| Master and per-track stem render paths, plus region/razor range orchestration | H | H | +| Project, custom, time-selection, project-region, and selected-region bounds | H | M | +| Include-metronome and optional secondary-format output | M | M | | Render queue | M | M | | Add rendered output back into project | M | M | -| Render metadata and filename wildcards | M | M | +| Render filename wildcards | M | M | | Render in place, consolidate track, freeze/unfreeze | H | H | | Batch audio converter | M | M | | DDP export | M | H | @@ -140,6 +164,7 @@ Ratings: | Project settings, notes, author/revision metadata | M | M | | Project templates and save-from-template flow | M | M | | Safe-mode project open / FX bypass recovery path | H | M | +| Session archive/unarchive | M | H | | Media import and drag/drop handling | H | M | | Missing media resolver | H | M | | Media explorer browse/import | M | M | @@ -152,11 +177,13 @@ Ratings: | Feature | Impact | Complexity | |---|---:|---:| | AI tools runtime status, install, cancel, reset flow | H | H | +| Feature-selective AI setup with hardware/model readiness and background progress | H | H | | Stem separation workflow with selectable stems and progress polling | H | H | | Stem separation result import into new tracks/clips | H | H | | AI track type and AI track header controls | M | H | -| Text-to-music generation workflow | H | H | -| Lyrics + style music generation workflow | H | H | +| ACE-Step text-to-music and lyrics-plus-style generation | H | H | +| Stable Audio 3 Medium text-to-audio generation with gated local snapshot import and license acknowledgement | H | H | +| Source-conditioned variation, inpaint-selection, and continue-clip workflows | H | H | | AI generation progress/cancel handling | M | H | ## Workflow / UI Customization @@ -165,11 +192,15 @@ Ratings: |---|---:|---:| | Central action registry powering menus, shortcuts, command palette | H | H | | Menu bar, main toolbar, custom toolbar strip/editor | H | M | -| Keyboard shortcuts modal and global shortcut handling | H | M | +| Searchable/printable keyboard-shortcuts modal with 19 built-in DAW profile families | H | H | +| Independent keyboard and mouse/scroll profiles with platform-aware labels and documented gesture targeting | H | H | +| Scoped multi-binding, conflict checks, intentional unassignment, and named custom keyboard-profile import/export | H | H | | Command palette | H | M | | Screensets/layout state | M | M | | Theme editor and custom theme state | M | M | -| Mouse modifier preferences | M | M | +| Persisted mouse modifier preferences and profile-specific wheel/drag behavior, synchronized to detached windows | M | H | +| Custom actions/macros | M | M | +| High-contrast theme, startup recovery surface, modal guards, and top-level error boundary | M | M | | Big clock and timecode display settings | M | M | | Help overlay and getting started guide | L | M | | App updater hooks | M | M | @@ -186,6 +217,7 @@ Ratings: | MCU-style control surface support | M | H | | Video window, video metadata/frame extraction, audio extraction path | M | H | | Surround/channel layout and VBAP panner code paths | M | H | +| Track/master channel-format state | M | M | | ARA host controller lifecycle and track ARA status plumbing | H | H | ## Implemented But Partial / Caveated @@ -199,8 +231,19 @@ These have real code surfaces, but should not be counted as fully delivered DAW | LTC output | Bridge stub exists, not a real implementation | | Live capture start/stop | Bridge stubs exist | | Media Explorer audio preview | UI exists; backend preview function appears to only acknowledge/log | -| AI continuation workflow | Present in workflow list but marked unavailable | | Drum editor / media pool | Store toggles/actions exist, but no mounted full UI components were found | +| Step sequencer | Store state and actions exist, but no mounted step-sequencer component was found; piano-roll step input is the supported workflow | | Master FX reorder | Track/input reorder exists; master reorder is noted as unsupported in UI | | Legacy `executeScript/loadScriptFile` bridge names | Stubbed, but newer `runScript/runScriptCode` paths are implemented | +| Public TONE3000 release | OAuth/catalog/download implementation and deterministic mocks exist; partner approval and a fresh-account release-candidate run remain external gates | +| NAM Rack sonic/noise acceptance | Deterministic DSP/state guards exist; tone, transition perception, and real-interface crackle/noise require audition of the exact release build | +| Selected-item render sources | The two choices are present, but the backend does not yet filter the render to selected clips | +| Render metadata | Metadata fields are shown as a disabled placeholder; the renderer does not write them yet | +| Online render | The current render-dialog control is a disabled UI placeholder | +| Specialist delivery workflows | Region matrix, DDP, batch conversion, immersive delivery, broadcast metadata, and CD-mastering behavior need focused release smoke/hardware validation before strong public claims | +| External sync and control surfaces | MTC, MIDI clock, OSC, and MCU-style paths need release validation with real devices | +| Video | FFmpeg-backed plumbing exists, but this is not a mature post-production video suite; FFmpeg is bundled on Windows and is an optional system dependency on macOS/Linux | +| Surround/immersive delivery | Channel-layout and VBAP paths exist; dedicated workflow and hardware validation remain open | +| AI generation availability and quality | Optional runtime, model licenses, local hardware, RAM/VRAM, and user audition determine availability and results; large generation models are not bundled with the core app | +| Subjective audio quality | Pitch, formant, stem, generation, and NAM tone/artifact claims require audition; automated diagnostics alone are not acceptance evidence | diff --git a/docs/input-profiles.md b/docs/input-profiles.md new file mode 100644 index 0000000..93254e6 --- /dev/null +++ b/docs/input-profiles.md @@ -0,0 +1,140 @@ +# Keyboard, Hotkey, Mouse, and Scroll Profiles + +OpenStudio can adopt the familiar input conventions of another DAW without +changing the project or audio engine. Keyboard and mouse/scroll behavior are +separate choices: for example, you can use Cubase-style keys with REAPER-style +timeline scrolling. + +## Choose a profile + +1. Open **Help > Keyboard Shortcuts**. +2. Choose a **Keyboard profile**. +3. Choose a separate **Mouse & scroll profile**. +4. Search the action list to see the effective keys and their active scope. + +The first-run profile card exposes both selectors; Getting Started shows the +current choices and directs you to the shortcut window. Preferences also shows the +current mouse behavior and exact modifier overrides. + +The selected keyboard base, mouse/scroll base, custom keyboard profiles, and +fine-grained per-gesture mouse overrides are persisted. Named/importable custom +profiles currently apply to the keyboard only. Mouse overrides are validated +before loading and are synchronized to detached editor windows with the active +base profiles. + +## Binding vocabulary + +- **Primary** is Control on Windows/Linux and Command on macOS. +- The legacy portable **Alt** token means Alt on Windows/Linux and physical + Control on macOS. Profile definitions use explicit **Option**, **Control**, + **Command**, or **Meta** whenever the physical modifier matters. +- Numpad bindings and physical `Code:` bindings remain distinct from the + character printed on a key. This prevents layout-dependent labels from + silently changing the intended physical shortcut. + +## Built-in profiles + +OpenStudio currently includes 19 built-in profile families: + +| OpenStudio | Pro Tools | Cubase / Nuendo | REAPER | Audacity | +|---|---|---|---|---| +| Logic Pro | FL Studio | Ableton Live | Studio One | Bitwig Studio | +| Reason | Cakewalk / Sonar | GarageBand | Digital Performer | Ardour | +| Adobe Audition | Mixcraft | Waveform | Renoise | | + +A profile maps documented source-DAW conventions onto equivalent OpenStudio +actions. It does not claim to reproduce commands for which OpenStudio has no +matching operation. Most profiles keep the OpenStudio binding when the source +profile does not define an override; explicit empty mappings prevent known +collisions or false equivalence. Digital Performer, Waveform, and Renoise use a +strict policy, so commands without a verified mapping remain unassigned. The +deliberate exception is `Esc` for closing an active modal, which remains an +application-level safety control. + +Profiles remain selectable on every supported OpenStudio platform. When the +source DAW is not native to the current operating system, the UI labels the +selection as cross-platform emulation. Printed key names are normalized for the +current platform, including Command/Control and Option/Alt distinctions. + +## Custom keyboard profiles + +The Keyboard Shortcuts window can create named profiles on top of any built-in +base profile. A custom profile can be created, duplicated, renamed, deleted, +exported to JSON, and imported again. + +For each action you can: + +- add more than one key combination; +- create an all-platform binding or a macOS, Windows, Linux, or fallback + override; +- intentionally disable the action for the selected target; +- remove an override and inherit the built-in profile again; +- review conflicts before accepting an overlapping binding. + +An imported profile is schema-checked, size-limited, normalized, and rejected +if it names unknown actions or invalid/unreachable key combinations. Imported +profiles become a copy with a fresh local identity rather than overwriting an +existing profile silently. + +## Context and scope + +The same key may be valid in different editors. OpenStudio resolves bindings by +action scope, including global, Timeline/ruler, track controls, Mixer, Piano +Roll, Pitch Editor, automation, browser, plug-in, modal, and contextual +surfaces. Text entry and active shortcut capture take precedence so typing in a +field does not accidentally run a DAW command. + +The action list is the authoritative view of the selected profile. Use +**Print** to generate a cheat sheet for the current profile and platform; static +shortcut examples in the manual show the OpenStudio default profile only. + +## Mouse and wheel safety + +Vendor mouse profiles enable only gestures that have a documented OpenStudio +equivalent and a valid hit target. Unsupported parameter-wheel gestures are +suppressed instead of falling through to a different OpenStudio value change. +Browser zoom protection and normal list/browser scrolling remain application +safety behavior, independent of the selected vendor profile. + +Some gestures are intentionally surface-specific. Examples include Cubase +fade/event-volume adjustment, Pro Tools waveform zoom, FL Studio clip/note +operations, and Cakewalk grouped console-fader changes. A gesture that requires +a missing OpenStudio state remains unassigned rather than changing a broader +control unexpectedly. + +## Verification and source notes + +The implementation is covered by unit tests for platform normalization, +dispatch precedence, strict/fallback policy, conflicts, import/export, +mouse/wheel resolution, undo transactions, detached-window base-profile sync, +and editor scopes. Playwright flows cover onboarding, base-profile and custom +keyboard-profile persistence, runtime switching, custom keyboard overrides, +keyboard capture, and representative wheel behavior. + +The built-in profile claims were last validated against official vendor +manuals, help pages, and shortcut sheets on **2026-08-21**. The primary source +set is: + +- [Avid Pro Tools](https://kb.avid.com/pkb/articles/en_US/Knowledge/Pro-Tools-Documentation), + [Steinberg Cubase/Nuendo](https://www.steinberg.help/r/cubase-pro/15.0/en/cubase_nuendo/topics/key_commands/key_commands_tool_category_c.html), + [REAPER](https://dlz.reaper.fm/userguide/ReaperUserGuide779a.pdf), and + [Audacity](https://manual.audacityteam.org/man/keyboard_shortcut_reference.html); +- [Apple Logic Pro](https://support.apple.com/en-mt/guide/logicpro/lgcp02bf31b6/mac) + and [GarageBand](https://support.apple.com/guide/garageband/gbnd715f33a0/mac), + [FL Studio](https://www.image-line.com/fl-studio-learning-content/fl-studio-online-manual/html/basics_shortcuts.htm), + [Ableton Live](https://www.ableton.com/en/manual/live-keyboard-shortcuts/), and + [Studio One](https://pae-web.presonusmusic.com/downloads/products/pdf/Studio_One_Pro_7_Key_Command_Sheet.pdf); +- [Bitwig Studio](https://www.bitwig.com/userguide/latest/the_dashboard/), + [Reason](https://docs.reasonstudios.com/reason14/key-commands), + [Cakewalk/Sonar](https://help.cakewalk.com/hc/en-us/articles/360036997613-Cakewalk-Sonar-Keyboard-Shortcuts), + [Digital Performer](https://cdn-data.motu.com/manuals/software/dp/v113/Digital%20Performer%20User%20Guide.pdf), + and [Ardour](https://manual.ardour.org/setting-up-your-system/keyboard-shortcuts/); +- [Adobe Audition](https://helpx.adobe.com/audition/desktop/keyboard-shortcuts/default-keyboard-shortcuts.html), + [Mixcraft](https://acoustica.com/mixcraft-10-manual/keyboard-shortcuts), + [Waveform](https://www.tracktion.com/training/manuals), and + [Renoise](https://tutorials.renoise.com/wiki/Keyboard_Shortcuts). + +These sources describe each product's **published defaults**. They are not a +promise to reproduce a user's customized source-DAW map. OpenStudio's own named +custom profiles and overrides are a separate, persisted layer on top of the +selected published-default base. diff --git a/docs/macos-audio-fixes.md b/docs/macos-audio-fixes.md deleted file mode 100644 index 1233108..0000000 --- a/docs/macos-audio-fixes.md +++ /dev/null @@ -1,209 +0,0 @@ -# macOS Audio Fixes — Root Cause Analysis & Implementation Notes - -**Date:** 2026-04-13 -**Affects:** macOS arm64 (Apple Silicon), all macOS releases -**Files changed:** -- `Source/StemSeparator.cpp` -- `CMakeLists.txt` - ---- - -## Background - -Two separate bugs were found during macOS testing with an Audient ID14 USB audio interface. Both trace back to the same root condition: **the app was distributed without notarization**, triggering macOS Gatekeeper quarantine on first launch. The quarantine created a cascade of failures in two independent subsystems. - -The user had to run `xattr -dr com.apple.quarantine /Applications/OpenStudio.app` manually before the app would open. This is the key diagnostic signal that connected both bugs. - ---- - -## Bug 1 — AI Tools Installation Fails on macOS arm64 - -### Symptom - -Install log shows the download, verification, and extraction all succeeding, then: - -```json -{ - "phase": "probe", - "event": "runtime_probe_finished", - "baseRuntimeReady": false, - "runtimeReady": false, - "selectedBackend": "cpu", - "supportedBackends": "" -} -``` - -`supportedBackends: ""` (empty string, not even `"cpu"`) is the diagnostic fingerprint — it means `probeRuntimeCapabilities()` returned a **default-initialized struct**, i.e., it exited before parsing any JSON from the probe process. - -### Root Cause Trace - -#### Step 1 — Why `supportedBackends` is empty - -`StemSeparator::probeRuntimeCapabilities()` has several early-return paths that return a default `RuntimeCapabilities{}` struct before reaching the JSON parse loop. The log call at the end (`capabilities.supportedBackends.joinIntoString(",")`) then produces an empty string because the array was never populated. The "add cpu if empty" safety net only runs after a successful parse — not on early returns. - -#### Step 2 — Which early-return fired - -The Python binary path check passes (the extracted Python file exists on disk — confirmed by the `findPythonInRuntimeRoot` check at line 1674 succeeding). The failure is at the subprocess launch: - -```cpp -if (! probe.start(command) || ! probe.waitForProcessToFinish(30000)) - return capabilities; -``` - -`juce::ChildProcess::start()` uses `posix_spawn()` internally on macOS. `posix_spawn()` returns `EACCES` and the call returns `false` when the target binary is not executable. - -#### Step 3 — Why the binary is not executable - -`juce::ZipFile::uncompressTo()` does not restore Unix file permission bits from the zip's external file attributes. The Python binary inside the AI runtime archive has execute permission in the zip metadata, but after extraction the file mode is set without the `x` bit — typically `-rw-r--r--` instead of `-rwxr-xr-x`. - -This is a known limitation of JUCE's zip extraction: it writes file content correctly but ignores the Unix permission field stored in the zip's local file headers. - -#### Step 4 — The quarantine layer on top - -Even if the execute bit were somehow present, macOS applies the `com.apple.quarantine` extended attribute to all files extracted from a downloaded zip archive (because the zip itself was downloaded from the internet and carries the quarantine attribute). Gatekeeper blocks execution of quarantined binaries launched by a child process. - -The user's `xattr -dr` on the app bundle only cleared the `.app` itself. Files extracted later to `~/Library/Application Support/OpenStudio/stem-runtime/` were quarantined separately and untouched by that command. - -Both problems apply simultaneously on a fresh install: -- No execute bit → `posix_spawn()` fails with `EACCES` -- Quarantine attribute → Gatekeeper blocks even if execute bit is present - -### Fix - -**File:** `Source/StemSeparator.cpp` — `extractRuntimeArchive()` - -After the successful `copyDirectoryTo(destinationRoot)` call and before cleanup, a `#if JUCE_MAC` block is inserted that performs two post-extraction steps: - -**Step A — Restore execute bits using POSIX `chmod()`** - -A lambda (`restoreExecuteBit`) iterates every regular file under `bin/` and `lib/` using `juce::RangedDirectoryIterator`, reads current permissions with `::stat()`, and ORs in `S_IXUSR | S_IXGRP | S_IXOTH` via `::chmod()`. A separate block does the same for the Python binary itself (which may live outside `bin/` depending on the archive layout). - -`chmod()` is used directly instead of spawning a subprocess because: -- It avoids the overhead of a child process -- It avoids the circular problem of needing a working Python to fix Python's own permissions -- `<sys/stat.h>` is always available on macOS/Linux - -**Step B — Strip the quarantine attribute using `xattr -rd`** - -`xattr` is a macOS system tool that always exists at a known path. It is invoked via `juce::ChildProcess` with `xattr -rd com.apple.quarantine <destinationRoot>`. The `-r` flag makes it recursive across the entire runtime directory. Failure is treated as non-fatal — the chmod step (Step A) is the primary fix; the quarantine strip is defense-in-depth. - -There is no single POSIX API equivalent to a recursive `removexattr` over a directory tree, so the subprocess approach is necessary for this step. - -**Additional changes in `probeRuntimeCapabilities()`:** - -- All early-return paths now emit a structured JSON log line via `appendAiToolsLogLine()` explaining which check failed (python not found, script not found, process start failure, timeout, bad exit code). Previously these returned silently, making the failure impossible to diagnose from the log alone. -- Probe subprocess timeout increased from 15 000 ms to 30 000 ms. Even after quarantine is stripped, macOS Gatekeeper performs a one-time scan of newly executable binaries. On slow or heavily loaded systems this scan can exceed 15 seconds. - -### Platform Impact - -The chmod/xattr block is wrapped in `#if JUCE_MAC` and does not compile on Windows or Linux. The `#include <sys/stat.h>` is wrapped in `#if JUCE_MAC || JUCE_LINUX` — it is a standard POSIX header on Linux and a harmless addition there. The timeout and logging changes are cross-platform but additive only (longer timeout, extra log lines on failure). - ---- - -## Bug 2 — Input Monitoring Produces No Audio (No Meter Movement) - -### Symptom - -- Audient ID14 connected via USB, inputs visible in the device selector inside the app -- Track added, armed for recording, FX loaded -- No audio heard through monitoring -- No movement in the channel strip peak meters at all - -### Why This Is Not a Driver Issue - -The Audient ID14 is a class-compliant USB audio device. macOS has native Core Audio support for it with no third-party driver required. Other DAWs working with the same device confirms the hardware and driver layer are fine. - -### Root Cause Trace - -#### Step 1 — Enumeration vs. capture - -The app can list the interface's inputs (channel count, names) because device enumeration uses `AudioObjectGetPropertyData` with `kAudioDevicePropertyStreamConfiguration`, which does **not** require microphone permission. - -Actual audio capture — reading samples from `inputChannelData` in the audio callback — goes through a different code path that macOS gates behind the privacy permission system. - -#### Step 2 — macOS treats all audio input as "microphone" - -On macOS, **the "Microphone" privacy permission covers every audio input source** — built-in microphone, USB audio interfaces, Thunderbolt interfaces, HDMI audio return, everything. It is not specific to the physical built-in microphone. The OS does not distinguish between a laptop mic and a professional audio interface at the privacy layer. - -When microphone permission is denied or not yet requested: -- `numInputChannels` in the audio callback may still be > 0 (the device is open) -- But all values in `inputChannelData` are zero — silence -- The monitoring path in `AudioEngine::audioDeviceIOCallbackWithContext()` faithfully copies those zeros into the track buffer -- The track FX chain processes zeros, the meter computes 0.0 RMS, the channel strip shows nothing - -#### Step 3 — The permission was never requested - -The `NSMicrophoneUsageDescription` key was missing from the app's `Info.plist`. This key is required for macOS to allow the app to use audio input at all. When the key is absent: - -- macOS never shows a permission dialog -- The app's permission state stays at "not determined" (or is silently treated as denied) -- Core Audio provides zero input data on every callback - -This key must be set in the generated `Info.plist` at build time. In a JUCE CMake project, it is controlled through the `juce_add_gui_app()` call. - -**The existing `juce_add_gui_app()` call in `CMakeLists.txt`:** - -```cmake -juce_add_gui_app(OpenStudio - PRODUCT_NAME "OpenStudio" - VERSION ${PROJECT_VERSION} - ICON_BIG "${CMAKE_CURRENT_SOURCE_DIR}/assets/icon-256x256.png" - ICON_SMALL "${CMAKE_CURRENT_SOURCE_DIR}/assets/icon-16x16.png" - # MICROPHONE_PERMISSION_ENABLED and MICROPHONE_PERMISSION_TEXT were absent -) -``` - -#### Step 4 — The quarantine connection - -The app being quarantined on first launch is related but not the direct cause. The direct cause is the missing plist key. The quarantine connection is: - -- A quarantined app on its first launch may have its entitlement and permission requests suppressed or fail silently by Gatekeeper -- If the key had been present but the first launch was quarantined, macOS might have failed to record the "not determined" state correctly -- After the user ran `xattr -dr` on the app, subsequent launches no longer trigger Gatekeeper — but the permission was already never requested, so audio input still returns zeros - -### Fix - -**File:** `CMakeLists.txt` — `juce_add_gui_app()` call - -Two properties added: - -```cmake -MICROPHONE_PERMISSION_ENABLED TRUE -MICROPHONE_PERMISSION_TEXT "OpenStudio needs access to your audio input to record from microphones and audio interfaces." -``` - -`MICROPHONE_PERMISSION_ENABLED TRUE` tells JUCE's CMake module to emit `NSMicrophoneUsageDescription` into the generated `Info.plist`. `MICROPHONE_PERMISSION_TEXT` is the string value for that key — it is shown to the user in the macOS permission dialog and in System Settings → Privacy → Microphone. - -On the first launch of the rebuilt app, macOS will display a one-time permission dialog. After the user grants access, Core Audio provides real input data and monitoring works. - -### Platform Impact - -`MICROPHONE_PERMISSION_ENABLED` and `MICROPHONE_PERMISSION_TEXT` are Apple-platform-only properties in JUCE's CMake module. The module does not act on them for Windows or Linux builds — they are silently ignored. This change has zero effect on Windows and Linux builds. - -### User-Facing Behavior Change - -**New installs:** The permission dialog appears on first launch. Expected and correct. - -**Existing installs (users who had the old build):** Their stored permission state is "not determined" (the key was never in the plist, so macOS never asked). On first launch of the updated build, macOS will show the dialog. They grant access, and monitoring works from that point forward. - -**If a user previously explicitly denied access** (unlikely given the permission dialog was never shown, but possible through System Settings): They need to enable it manually at System Settings → Privacy & Security → Microphone. - -To reset a specific installation's stored permission state from the terminal: - -```bash -tccutil reset Microphone <bundle-identifier> -# e.g.: tccutil reset Microphone in.openstudio.app -``` - ---- - -## Summary of All Changes - -| File | Change | Reason | -|---|---|---| -| `Source/StemSeparator.cpp` | `#include <sys/stat.h>` under `#if JUCE_MAC \|\| JUCE_LINUX` | Required for POSIX `stat()` and `chmod()` | -| `Source/StemSeparator.cpp` | Post-extraction `chmod +x` block in `extractRuntimeArchive()` | `juce::ZipFile::uncompressTo()` does not restore Unix execute bits; Python binary extracted without `+x` causes `posix_spawn()` to return `EACCES` | -| `Source/StemSeparator.cpp` | Post-extraction `xattr -rd com.apple.quarantine` in `extractRuntimeArchive()` | macOS applies quarantine to all files extracted from a downloaded zip; Gatekeeper blocks execution of quarantined binaries | -| `Source/StemSeparator.cpp` | Diagnostic log lines on all early returns in `probeRuntimeCapabilities()` | All early returns were previously silent; log now records which specific check failed | -| `Source/StemSeparator.cpp` | Probe timeout 15 000 ms → 30 000 ms | First-time Gatekeeper scan of a newly executable binary can exceed 15 s on loaded systems | -| `CMakeLists.txt` | `MICROPHONE_PERMISSION_ENABLED TRUE` + `MICROPHONE_PERMISSION_TEXT` in `juce_add_gui_app()` | Without `NSMicrophoneUsageDescription` in `Info.plist`, macOS never prompts for audio input permission and Core Audio silently returns zeros for all input | diff --git a/docs/midi-editor.md b/docs/midi-editor.md new file mode 100644 index 0000000..b757a16 --- /dev/null +++ b/docs/midi-editor.md @@ -0,0 +1,60 @@ +# MIDI Editor + +OpenStudio's MIDI editor combines arrange-view MIDI items with a docked piano +roll, controller lanes, instrument routing, source-window editing, and +project/export serialization. + +## Current contract + +- MIDI and instrument tracks share the main timeline with audio tracks. +- Clip move, resize, loop, slip, split, copy, duplicate, delete, mute, lock, + repeat, and compatible cross-track operations are undoable. +- The piano roll supports draw, erase, move, resize, split, glue, mute, + audition, range selection, multi-item reference editing, and note inspector + changes. +- Controller editing covers velocity, note-off velocity, probability, + variance, pitch bend, CC, 14-bit CC, program/bank select, channel pressure, + poly pressure, curves, transforms, and lane management where exposed. +- Project save/reload, backend synchronization, playback scheduling, render, + freeze, and MIDI export preserve supported event metadata. + +## Grid, snap, and quantize contract + +- The arrange view and docked piano roll use one project-persisted Snap toggle, + Snap Type, Grid Type, active Quantize Preset, and custom preset collection. +- Grid choices include Bar, Beat, straight, triplet, dotted, and time values; + Use Quantize resolves through the active preset, while Adapt to Zoom selects + a readable musical subdivision. Visual grid-line thinning must not change the + actual edit/snap interval. +- Snap types cover Grid, Grid Relative, Events, Shuffle, Cursor, and their + supported combinations. MIDI draw, move, resize, split, and ruler gestures + use the selected grid when Snap is enabled; Ctrl/Command temporarily permits + off-grid placement. +- Quantize applies the active preset to starts, ends, both, or note length and + supports strength, swing/groove, tuplets, catch/safe ranges, roughness, and + moving controllers. Length Quantize may use an explicit value or Quantize + Link, and factory/custom preset save, rename, remove, restore, and project + recall preserve the selected workflow. + +Automated browser/native coverage is `pass` for those deterministic contracts. +Overall workflow parity remains `partial` until a musician completes the manual +REAPER/Cubase-style acceptance below. + +## Manual acceptance + +- [ ] Arrange a multi-item MIDI passage using move, loop, slip, split, + cross-track copy, and undo/redo without losing source context. +- [ ] Edit notes and multiple controller lanes in the docked editor without + accidental tool/selection changes. +- [ ] Confirm note, CC, pitch-bend, pressure, program, and bank data sound and + export as displayed. +- [ ] Save, close, reopen, and render a combined MIDI/instrument project. +- [ ] Compare the complete interaction flow with the intended REAPER arrange + and Cubase Key Editor references and record concrete remaining gaps. +- [ ] In a browser/WebView interaction run, verify Snap on, Ctrl/Command-drag + off-grid placement, draw/resize/split on the selected grid, quantize with the + active preset, and Length Quantize with both a fixed value and Quantize Link. + +Implementation history and old screenshot-by-screenshot matrices belong in Git +history. New behavior should be documented here with its test or manual +acceptance criterion. diff --git a/docs/nam-rack.md b/docs/nam-rack.md new file mode 100644 index 0000000..1454956 --- /dev/null +++ b/docs/nam-rack.md @@ -0,0 +1,607 @@ +# NAM Rack + +The NAM Rack is OpenStudio's free, open-source guitar and bass workspace. It hosts Neural +Amp Modeler A1 and A2 pedal, amp, or full-rig captures inside the DAW, surrounds them +with native pedals and studio effects, loads cabinet impulse responses, saves +complete tones, and optionally connects to TONE3000. + +OpenStudio does not charge to unlock the rack or its built-in effects. NAM and +OpenStudio are open source. Third-party captures and impulse responses still +retain their creators' licenses. + +## Musician workflow + +1. Add the NAM Rack to a track, choose the live instrument input, and select the + Guitar or Bass profile. +2. Set the device buffer and input trim, then tune. +3. Load a local A1/A2 capture or connect TONE3000. When a tone pack contains + more than one capture, open its capture list and choose the exact child + before auditioning or using it. +4. If the capture is amp-only, load a cabinet IR. A full-rig capture that + already contains a cabinet bypasses the separate cabinet stage. +5. Shape the front end with the native pedalboard and finish the sound with EQ, + modulation, delay, and reverb. +6. Save the complete rack as a tone or project state. + +### Multi-capture packs and audition + +TONE3000 results are presented as tone packs rather than duplicating one card +per child model. A pack reports its declared capture count; opening **View +Captures** hydrates the available children and shows each name, NAM +architecture, and topology such as **RAW / AMP ONLY**, **CAB EMBEDDED**, +**PEDAL**, **PREAMP**, or **STUDIO**. + +For a pack with more than one capture, the pack row is not an implicit model +choice. The user must select a concrete child before **Audition** or **Use** is +enabled. A selection is identified by tone ID plus model ID; URL-only children +use a normalized URL identity so two captures without numeric IDs do not +collapse together. The `tone:0` pack sentinel is never treated as a real child +capture. + +The lifecycle is deliberately explicit: + +1. Selecting a child updates only the pending UI selection; it does not change + the audible rack. +2. **Audition** prepares/downloads that child as needed and temporarily + publishes it to the target slot. Auditioning another child supersedes the + first preview. +3. **Stop** or Cancel restores the complete baseline that existed before the + preview, including capture, cabinet state, and relevant mix/power values. +4. **Use** commits that exact child and its durable source metadata. It can then + be recalled, pinned in filtered library views, and recovered after restart. +5. Opening the capture selector and using another child replaces the current + capture. Amp power bypasses/re-enables an Amp capture without losing it, and + Amp **Unload** clears that slot. Pedal NAM uses Pedal Mix for bypass; removing + the broader Pedal module also clears its pre-Amp module settings. +6. A CAB EMBEDDED/full-rig child bypasses the external Cab stage without + deleting the chosen IR. Returning to an amp-only child restores the external + cabinet workflow. + +Model preparation, stale-request rejection, and rollback are deterministic +test contracts. The user interface also exposes the child list and actions to +keyboard/assistive navigation. Absence of audible clicks, dropouts, or +real-interface crackle remains `not_asserted` until the exact release artifact +is auditioned at small and normal buffers. + +The current audible route is: + +```text +Input trim + -> Gate + -> Compressor + -> Stereo Poly Octaver + -> EQ Boost / pre-EQ + -> Precision Drive + -> Distortion + -> optional A1/A2 Pedal NAM capture + -> A1/A2 Amp or Full-Rig NAM capture + -> Cabinet IR and cabinet shaping + -> Cabinet Space (early Room / optional Doubler) + -> reorderable EQ / modulation / delay / reverb + -> Output trim + -> final stereo-linked finite/level safety guard + -> meters +``` + +The tuner observes the input without becoming part of the audible chain. +Pedal NAM is an optional serial pre-amp slot. When loaded, its calibrated wet +path and the native pedalboard feed the Amp/Full-Rig NAM slot; a partial Pedal +mix preserves the single-input dry/wet balance before a following mono Amp NAM. +A pedal-only capture is not a complete amp/cab tone by itself; it normally needs +a following Amp/Full-Rig and, for an amp-only capture, a cabinet IR. +The former global live Transpose, Chaos mode, Glitch, and Laser product +controls are retired. The new Cabinet Space Doubler is a separate post-cab +presentation effect, not a revival of the retired global control. Legacy Laser +fields are ignored and pruned when old projects are restored; they are not +exposed or processed by the active rack. + +### Guitar/Bass instrument-profile contract + +Instrument Profile is a non-destructive voicing selector. It changes only the +frequency-, tracking-, and low-end-sensitive behavior that should follow the +instrument. It never rewrites visible control values or silently replaces a +loaded NAM capture, cabinet IR, input/output trim, gate threshold, compressor +settings, time/mix controls, or explicit HPF/LPF choices. + +The current profile-aware components are: + +- EQ Boost keeps its eight stable preset/automation IDs, but Guitar presents + `120/250/500/1k/2.5k/5k/8k/12k` and Bass presents + `50/120/250/500/800/1.6k/4.5k/10k`. +- The stereo poly Octaver changes its analysis and pitch-tracking profile so + B0/E1 bass fundamentals remain supported without weakening Guitar tracking. +- Precision Drive, Distortion, the Amp input wrapper, and the Amp tone stack + move their hidden low-frequency split/weighting and tone centres downward + for Bass. Their visible Drive, Attack, Bright, Voice, Bass, Mid, Treble, and + Presence values stay untouched. +- The post-cab Graphic EQ retains its fixed nine labels; in Bass mode its 65 Hz + band becomes a low shelf instead of a narrow peaking band. +- Bass modulation keeps a unity direct path while the existing wet-path high + pass prevents the modulated branch from replacing the fundamental. +- Bass Delay keeps unity direct audio and applies a higher high-pass only to + repeats/feedback. Time, feedback, mix, modulation, ducking, mode, ping-pong, + and sync remain the user's values. +- Plate, Hall, and Room Reverb shorten only the low-band decay ratio in Bass + mode. The visible decay and low-cut values remain exact. Studio retains its + legacy mapping for old-preset compatibility. + +Gate, Compressor, Cabinet/IR, Cabinet Space, calibration, trims, and tuner are +deliberately profile-invariant. Compressor detector HPF and cabinet/reverb +cutoffs are explicit creative controls, not hidden selector defaults. The tuner +already covers 27.5-1320 Hz, so it needs no mode-dependent range change. + +Library filtering follows the profile only as a discovery aid. Untagged/shared +captures remain visible, an explicitly opposite-tagged active capture remains +loaded and is pinned in the list, and changing profile never downloads, swaps, +or unloads an asset. A factory-template label is cleared if that template no +longer matches the chosen profile; the resulting control state remains intact. + +### Tuner behavior + +Opening the tuner explicitly subscribes that NAM Rack to its dry hardware-input +route; record arm and input monitoring are not required. Multiple rack windows +use independent subscriber IDs, so closing one window cannot disable another. +The most recently opened tuner owns the analysis route until it closes, then +the prior subscriber resumes. A master-rack tuner explicitly observes global +hardware Input 1. + +The audio callback only selects the strongest routed channel and copies it into +a preallocated lock-free FIFO. A low-priority worker applies anti-aliased +downsampling, full-range MPM/NSDF pitch detection, parabolic period refinement, +and a temporal tracker. This keeps analysis out of the audible path and adds no +audio latency. + +The supported range is 27.5-1320 Hz, covering B0/E1 bass fundamentals through +upper guitar. Estimates are converted to absolute musical cents, median +filtered, and confidence-weighted before display. Three consistent frames +acquire a note, with the first pick-heavy window deliberately deweighted. +Large note or octave changes require repeated agreement, and note-name +hysteresis prevents boundary flicker. A missing estimate enters `Holding`: the +last average remains unchanged for about 450 ms, then fades and clears around +1.2 seconds after genuinely missing pitch. Closing the final subscribed tuner +disables the worker-side analysis. The worker discards stale queued audio +rather than letting CPU pressure turn into seconds of display lag. + +## Why A2 matters + +NAM A2 is the current Neural Amp Modeler architecture. TONE3000 and the NAM +project describe it as a more accurate and more efficient successor while +keeping the model format and inference ecosystem open. OpenStudio loads both A1 +and A2 so existing libraries remain useful while new A2 captures can take +advantage of the newer architecture. + +That gives OpenStudio a credible free alternative in the same creative category +as AmpliTube, Guitar Rig, and Neural DSP plug-ins: a playable amp-capture rig +with pedals, cabinets, effects, presets, and DAW recall. It is not an objective +claim that every capture beats every commercial product. Capture quality, +interface calibration, cabinet choice, and the player still determine the +result, and subjective comparison remains a musician's decision. + +## Architecture + +The main implementation lives in: + +- `Source/BuiltInEffects2.h/.cpp` — rack DSP, A1/A2 model preparation and + processing, cabinet convolution, effects, transitions, calibration, state, + latency, and diagnostics. +- `Source/NAMCabPresentation.h/.cpp` — bounded post-cab early-room and + doubler presentation, transient protection, mono compatibility, and spatial + diagnostics. +- `Source/NAMPolyOctaver.h/.cpp` — independent-channel ERB phase-scaling + octave voices used by the current stereo/polyphonic Octaver. +- `Source/TunerPitchTracker.h/.cpp` — real-time-safe input tap, background + MPM/NSDF detector, sustained-note averaging, and hold/release state. +- `Source/MainComponent.cpp` — native bridge, TONE3000 OAuth/search/download, + local library, and secure token persistence. +- `frontend/src/components/NAMRackPanel.tsx` — rack application state and user + workflow. +- `frontend/src/components/NAMRackDesignPort.tsx` — hardware-style stage UI. +- `frontend/src/components/NAMExplorer.tsx` — local/TONE3000 browsing, + preview, installation, and recovery. +- `frontend/src/services/NativeBridge.ts` — typed native API and deterministic + development mocks. + +### Real-time contract + +- Model parsing, file I/O, allocation, and convolution preparation occur away + from the audio callback. +- Prepared models and IRs are published atomically and retired after readers + have left the previous graph. +- The callback uses preallocated buffers and bounded work. +- Sample-rate conversion is explicit around NAM models whose expected rate + differs from the host. +- Fixed latency is reported to the host, with aligned dry paths during bypass + and crossfade transitions. +- Precision Drive and Distortion share the selected 2x/4x/8x nonlinear rate + island. It contributes one host-reported latency for every pedal power + combination; neither stage performs nested resampling. +- No timing result from one machine is marketed as a universal CPU claim. + +### Input routing and stereo NAM contract + +The DAW track route is the sole input-topology authority. A one-channel route +uses the mono NAM path. A route with two or more channels uses the stereo path +automatically when every loaded NAM slot has either two prepared 1x1 lanes or +a native 2x2 graph; if a loaded slot cannot support stereo, the Rack safely +falls back to mono. Empty slots do not prevent the remaining stereo effects +from processing a stereo track. + +The standard NAM capture used by the public ecosystem is normally a stateful +1-input/1-output processor. For stereo processing, its atomically published +owner contains two independently constructed and prepared `nam::DSP` graphs. +Left and right have separate model, +resampler, FIFO, dry-delay, calibration, fault, output-history, and NAM Slim +activation state. The callback evaluates the lanes sequentially with shared +preallocated scratch memory, but stages both results until the pair completes; +a runtime fault in either graph therefore publishes latency-matched dry for both +channels in that same callback, never one wet lane beside one dry lane. The Rack +never clocks one DSP object twice. A native 2x2 NAM model, if one is loaded, runs +once and does not receive a duplicate wrapper graph. Cabinet convolution +continues to process the resulting stereo lanes. + +Changing among routing modes uses an 8 ms fade-out, a muted 24 ms plus reported +latency state-prime interval, and a 12 ms fade-in. The graph mode changes only at +zero gain, and the envelope is before post effects so existing delay/reverb +tails remain continuous. This avoids exposing a cold or stale inactive lane and +does not allocate, lock, reset a graph, or perform I/O on the audio callback. + +Both 1x1 lanes are constructed off the callback. A failed replacement cannot +disturb an already active pair. On a first load, if only the optional right graph +cannot be constructed or prepared, the ready primary graph remains available +for mono routing. Parallel lanes add no second latency contribution, but stereo +processing is expected to approach twice the NAM inference cost. Mono routing +processes only the primary graph. + +There is no Rack-level Mono/Stereo preference. The retired `inputMode` key is +absent from the public schema and is ignored and pruned from legacy project, +preset, A/B, baseline, portable-state, and direct-setter paths. Physical input +selection and track channel width remain DAW responsibilities. Diagnostics +publish the automatic and effective mode so the UI can explain transitions and +pause the mono-only Doubler without introducing another user setting. Loading a +compatible model or changing the DAW track width activates the corresponding +route through the same muted handoff described above. + +### Meter topology contract + +The input meter measures the routed signal before Input Trim. A configured mono +source renders one full-width lane; a configured stereo source renders genuine +L/R lanes. This presentation follows `routedInputChannelCount`, not the +effective NAM graph, so a stereo route remains visibly stereo if a mono-only +capture forces an internal fallback. Returning to mono clears the hidden right +peak and hold, and the numeric level and clip indication use only visible lanes. + +The output meter always renders independent L/R peaks from the final Rack output +boundary. Linked input/output peak values remain compatibility fallbacks for an +older native build; the frontend never invents stereo values from a linked peak. + +Low-buffer timing results are machine/build-specific `diagnostic_only` +evidence, not proof of ASIO stability. Driver safety and subjective stereo +presentation still require testing on the target system and exact release build. + +### Cabinet Space presentation contract + +Cabinet Space is a fixed post-cab presentation stage, before the reorderable EQ, +modulation, Delay, and Reverb. It also runs for captures with an embedded cab; +placing it inside the external IR function would incorrectly skip those rigs. +Room accepts new input only from an engaged external Cab/IR or an audible, +engaged Amp/Full-Rig capture with an embedded cabinet. With an amp-only capture +and the external Cab off, or with both Amp and Cab off, Room may remain armed +and its existing history may drain over unity dry, but raw DI cannot enter a new +tail; the UI reports **No cab source**. + +Room Amount/Width feed a deterministic asymmetric 2x2 early-reflection field. +Doubler Mix/Spread and its 3-20 ms Delay (4.5 ms by default) add two independently +drifting short-delay voices around the selected time. Doubler generates stereo +only for a routed mono source; a true-stereo source pauses it with an explanation +without rewriting its saved enable, mix, delay, or spread. The close cab signal +remains an unattenuated centre anchor and the generated side is mixed as `+S/-S`, +so summing the output to mono cancels the added side rather than comb-filtering +the direct tone. + +The design follows the practical contracts exposed by current guitar products: +mono can become stereo at a component boundary, while true stereo input remains +stereo; a doubler uses millisecond-scale spread; and cabinet presentation is +separate from the late Delay/Reverb. It also follows the DAFx open-source +widener findings that low frequencies should remain more centred and that a +decorrelated path needs transient handling. Accordingly, two cascaded side +high-passes keep bass anchored, and a shared onset envelope ducks only the wet +Room/Doubler fields (up to about 3/6 dB respectively) while leaving the direct +pick attack untouched. + +The processor uses preallocated feed-forward early/Doubler paths plus a bounded, +damped four-line late-room network, with no runtime allocation, locks, logging, +or I/O. Its deterministic gates cover exact bypass, reset/partition equivalence, +mono-fold cancellation, mono-to-stereo generation, the 3.1 ms first reflection, +44.1/48/96 kHz operation, wet-only low-frequency side/mid balance, prefilled +automation, transient recovery, NaN/Inf recovery, bounded tail, and 8-sample +component timing. Wall-clock scheduler outliers remain diagnostic-only. +Perceived externalisation, naturalness, and similarity to a named product are +still `not_asserted` until a level-matched musician audition. + +Cabinet Space is controlled independently from the external Cab/IR switch. In +the compact chain, its own power control restores the last Room Amount and +Doubler Mix (or starts at 22% Room / 12% Doubler); switching it off writes both +amounts to zero. In Cab > Device Controls, Room Amount and Doubler Mix are the +individual enables: either may be zero while the other remains audible, and +Width/Spread shape only their corresponding active field. + +### Native pre-amp pedal level contract + +Precision Drive and Distortion use one current pre-release implementation. They +share a `+12 dBu` native-pedal operating reference before the Amp NAM stage. If +the interface/rack calibration reference is `R dBu`, the shared nonlinear island +receives `R - 12 dB` before the selected shared 2x/4x/8x processing and applies +the exact reciprocal gain afterward. This keeps the represented analog pedal +level stable when the interface reference changes and avoids applying the +conversion twice when both pedals are stacked. + +Precision Drive is a full-wet overdrive circuit, not an EQ-only boost. Attack +sets a frequency-selective feedback split: low frequencies retain the unity path +while upper lows and mids receive Drive-dependent gain into an asymmetric +nonlinear cell. Bright shapes the post-cell bandwidth, a DC blocker removes the +intentional asymmetry's offset, and Volume is an exact post-circuit gain. The +current default Volume is `+9 dB`, giving the pedal enough output to push a clean +Amp NAM; a saved explicit nonzero Volume remains the user's value. + +Precision Gate is a local, input-keyed control whose Off position is exact +unity. Its stereo-linked detector reads the untouched calibrated island input, +while its gain is applied after the complete Precision circuit and Volume but +before Distortion. Threshold, hold, detector release, and smooth closing follow +the selected amount; reopening is fast. The nonlinear circuit remains warm +during closure, and linking the envelope never mixes audio between channels. + +Distortion is a clean-room modern-heavy design informed by Empress's published +Heavy/Heavy Menace behavior, not a circuit clone. It distributes gain across +three zero-centred nonlinear cells with filtering between cells, then feeds a +stateful diode stage. `Weight` is a pre-distortion high-pass macro (`Tight` to +`Thick`), Tone controls interstage/presence voicing, and Heavy, Extreme, and +Crunch select distinct gain-density ranges. Mix crossfades the latency-aligned +clean stage input against the complete distorted branch. A fixed `-2.5 dB` +internal calibration keeps ordinary high-gain transients below the rack's +emergency knee; Level is applied exactly once after the complete topology. +`Dist Gate` is a dedicated stereo-linked idle-noise gate. Its detector reads +the untouched calibrated drive-island input before Precision Drive, while its +gain is applied after the complete Distortion circuit, Mix, and Level. This +input-keyed/post-distortion placement leaves the approved open tone unchanged +while suppressing small-signal noise buildup when the input drops below the +selected threshold. The current `0.22` default uses 6 dB hysteresis, a 35 ms +hold, a fast reopen, and a smooth close; `0` is the exact-unity bypass. The +nonlinear state remains warm during closure, and the linked envelope applies +the same gain to both channels without mixing audio between them. +The public design references are Empress's +[Heavy Menace overview](https://empresseffects.com/products/heavy-menace) and +[Heavy/Heavy Menace design history](https://empresseffects.com/blogs/empress-blog/the-heavy-menace-celebrating-10-years-of-heavy). + +The shared island uses one latency-aligned power transition instead of +multiplying nested pedal fades. Mid-transition reversals remain continuous, and +reaching exact bypass performs bounded fixed-state cleanup. The shared IIR +oversampler is drained incrementally with zero input across ordinary bypass +callbacks rather than performing a capacity-sized reset in one low-buffer +callback. The path has independent left/right state and performs no runtime +allocation, lock, I/O, nested resampling, or lookahead. + +The serialized `namEffectsDspVersion` field is an internal migration schema, +not a user preference or a supported legacy-engine selector. Because NAM Rack +has not shipped, every recognized old or missing marker is translated to the +one current implementation during binary, project, tone, and portable-state +restore. For pre-current presets only, the former default Precision Volume of +`0 dB` maps to the current `+9 dB` default; explicit nonzero controls and +model/IR resources are preserved. No old pedal DSP remains selectable or +runnable after restore. + +### Other pre-NAM pedal contracts + +The current Compressor exposes `Comp`, explicit `Attack` (0.1-50 ms), explicit +`Release` (50-1000 ms), a neutral-at-centre 500 Hz `Tone` tilt, an `Intensity` +switch, true parallel `Mix`, signed `Level` (-18 to +18 dB), and detector HPF +choices Off/80/240 Hz. +`Comp` moves the threshold from -6 to -44 dB and the knee from 12 to 2 dB; +`Intensity` selects an 8:1 or 16:1 ratio. Its calibrated gain-reduction meter is +shown on the pedal. The +range and interaction model deliberately cover the useful overlap documented +by Dyna Comp, Cali76, Empress Compressor MKII, and Keeley Compressor Plus, +without claiming to copy their proprietary OTA/FET circuits or synthesising +their noise. + +The Compressor first builds its complete parallel signal and then applies the +signed Level control to that complete stage: + +```text +stage = (1 - Mix) * dry + Mix * compressed +output = dBToGain(Output) * stage +``` + +The child compressor therefore has no hidden positive makeup contribution. +After Compressor bypass has drained its 25 ms transition, its detector, +RMS envelope, filters, and lookahead state are reset before the next engage. +The stable Level automation/state ID remains `compressorVolumeDb`. The retired +pre-release `Detail` macro is accepted only by migration: it is translated once +to its exact former effective attack/release times, removed, and never selects +another compressor implementation. + +The Rack Graphic EQ is nine bands at 65/125/250/500 Hz and 1/2/4/8/16 kHz, +with +/-12 dB per band. Its HPF provides exact **Off** plus logarithmic +20-500 Hz travel; its LPF provides logarithmic 3-20 kHz travel plus clockwise +**Off**. Mirrored 6% endpoint detents, double-click-to-Off, value formatting, +automation, MIDI learn, and state all use the same mapping. Each filter retains +its last active cutoff while Off. + +The processing order is HPF, nine bands, LPF, then the separate smoothed +/-12 dB +Level control. Both edge filters are stereo 12 dB/oct Butterworth responses with +smoothed coefficients and short Off/On transitions. The upper 16 kHz band is a +high shelf rather than a near-Nyquist bell. Flat/Off and whole-module bypass are +transparent, filter state remains warm while bypassed, and the minimum-phase +path adds no latency, saturation, hidden makeup, or modelled hiss. Migration +defaults the filters to Off and Level to 0 dB while preserving established band +IDs and private last-active cutoffs. + +The former dedicated Tape Echo pedal is retired in NAM effects DSP version 17. +Its six `tapeEcho*` parameters are removed from processing, automation, schema, +and UI state. Legacy values are pruned without being mapped onto the post-FX +Delay, so recalling an older tone cannot overwrite that Delay's saved settings. +The existing post-FX Delay remains available and retains its Tape mode. + +Gate topology is unchanged. The rack uses `Stereo Poly Octaver`: independent L/R +instances of the MIT Terrarium-derived ERB-PS2 topology, with fixed 6:1 +multirate processing, 80 complex bands at `Fs/6`, fast phase scaling, and +polyphase reconstruction. Six-sample scheduling state is retained across host +callbacks, so arbitrary partitions are sample-exact. The production callback +performs no allocation, locking, I/O, coefficient design, or dynamic growth. + +Deterministic fixtures cover exact silence/bypass, L-only isolation, identical +stereo parity, reset and fixed-versus-uneven callback partitions, NaN/Inf +recovery, 110/220/440/880 Hz target generation, at least 70 dB stop-band +rejection, and 44.1/48/96 kHz operation. All reset/partition/leak/parity errors +were exactly zero in the final Release run. The optimized isolated 48 kHz / +8-sample both-voice path measured p50 `2.9 us`, p99 `8.0 us`, and zero deadline +misses on the test machine. These are correctness and machine-specific timing +checks only: perceived chord tracking, pick transients, voicing, and +product-reference quality remain `not_asserted` until musician audition. + +### Reverb + +The active rack/instrument reverb is true stereo and offers four Voice choices: +`Studio`, `Plate`, `Hall`, and `Room`. The stable stored controls are Mix, +Pre-delay, Low cut, Decay, Tone, Shimmer, Pad, and Engage, but the hardware UI +relabels and remaps the tone/texture macros for each voice: + +- Studio: **DECAY**, **TONE**, and **AIR**; +- Plate: **DECAY**, **DAMP**, and **SHIMMER**; +- Hall: **DECAY**, **DAMP**, and **MOTION** (pitch shimmer is disabled); +- Room: **SIZE**, **TONE**, and **EARLY** (pitch shimmer is disabled). + +The stored Decay range is 0.2-12 seconds. Room treats that control as Size and +maps it to an effective room-decay range of roughly 0.45-3.2 seconds. Plate, +Hall, and Room shorten the low-band decay in Bass mode while leaving the user's +stored controls intact; Studio keeps its compatibility mapping. + +Pad is a default-off additive texture over the unchanged selected Reverb, not a +replacement voice or an amount macro. It is sourced from an already diffused +reverb projection rather than dry guitar, and it never changes Mix, Pre-delay, +Low cut, Decay, Tone, Shimmer, or the Voice-specific texture mapping. Its +source-following body and separately bounded upper breath layer avoid feeding +the noise-like carrier through the long tank, limiting dense-note buildup while +retaining a sparse airy tail. Pad remains armed if Reverb is bypassed, crossfades +without resetting the base tail, and drains after Off. + +The wet architecture has no dry latency, runtime allocation, lock, or +convolution. Non-finite/catastrophic recursive state clears only the reverb +history and mutes that wet block; the rack's final stereo-linked guard remains +the normal finite/level safety stage. Freeze, ducking, and former advanced +controls are not part of the active public schema. Recognized pre-release +markers are migration input only and normalize to the current reverb engine +during restore. + +### State and recovery contract + +- A rack preset represents the complete creative tone. +- Device calibration is playback-environment state and does not silently travel + as creative preset state. +- Project state stores the exact child-capture/model identity, stable model/IR + source identity, and the current rack parameters. +- Exported rack presets reference local NAM/IR files and do not embed the asset + binaries. Recall on another machine may therefore require Locate, Search in + Folder, or supported TONE3000 re-download. +- Factory effect templates contain no capture or IR and shape the currently + loaded Amp/Full-Rig plus native stages. +- Fresh capture selections and direct Capture Library loads request **Full** + model quality independently for the Pedal and Amp slots. Explicit saved + **Economy** selections and the legacy global Slim value restore exactly; a + snapshot with no quality field migrates to Full rather than inheriting the + destination rack's current setting. Legacy global quality is expanded into + per-slot values before preset import or Compare verification. +- Preview is transactional: Cancel restores the prior audible state; Use + publishes the chosen asset; failed or superseded requests cannot overwrite a + newer choice. +- Preset selection has one verified identity: none, factory ID, or user name. + Loading drains pending parameter persistence, applies the complete snapshot, + verifies native readback and model resources, then commits that identity. + Failure keeps the prior identity and the manager open; serialized Previous and + Next navigation always resolves from the last verified identity. +- Compare recall is transactional. It captures a complete authoritative rollback + before mutation, verifies parameters, model resources, DSP version, stage + order, and identity after recall, and verifies the rollback independently + before claiming the prior rack was restored after any false return, exception, + or readback mismatch. +- Current `.ospreset` storage is authoritative: a same-name legacy `.s13preset` + must never overwrite it. Valid user migrations write the current form + atomically, while corrupt legacy or user files remain untouched. Runtime + factory originals are immutable and use migrated AppData shadows instead. +- The immediate in-rack recovery card detects missing Amp/Cab assets and exposes + Locate, Replace, and Bypass. Project-open missing-media recovery additionally + covers Pedal NAM paths and can offer Search in Folder, a library copy, Locate, + and supported TONE3000 re-download when provider metadata exists. +- Full-rig captures bypass the external cabinet without discarding the user's + previous cabinet selection. + +## TONE3000 connection + +Production builds receive the TONE3000 publishable `client_id` through +`TONE3000_PUBLISHABLE_KEY`. It is a public OAuth identifier, not a secret. Never +embed a server/client secret. + +The native sign-in flow: + +1. Creates a PKCE verifier, challenge, and state value. +2. Opens the normal TONE3000 authorize page in the default browser. +3. Listens on `http://127.0.0.1:18762/tone3000/callback`. +4. Verifies the returned state and exchanges the code with the verifier. +5. Stores tokens in the operating-system credential store: Windows DPAPI, + macOS Keychain, or Linux Secret Service through `secret-tool`. +6. Restores a returning session and refreshes it before an authenticated action + when required. + +A first-time user can sign up in the same browser flow. The application should +never ask the user to copy an access token into a normal product screen. + +### Search and transient-cache contract + +Typed text is a draft until one remote request is committed after a 400 ms +debounce. Enter and the Search button flush that same request immediately without +leaving a second delayed request. Every request owns an immutable key containing +query, architecture, category/gear, source tab, sort, page size, and page; a +search-intent change invalidates the previous generation synchronously, so a +late success, error, or completion cannot alter the new rows or busy state. + +In-flight requests and bounded LRU results are shared across Explorer remounts +for the current application session. A fresh cached result renders immediately; +stale rows may remain visible during background refresh. Filters, committed +query, appended pages, and scroll position survive remounts in that session. +Remote catalog results are not persisted without explicit TONE3000 approval; +installed/local assets and user presets remain durable local data. + +Infinite browsing issues at most one request for each page/key, deduplicates by +stable ID, and provides an accessible Load More fallback. A failed append keeps +the accumulated rows and retries as an append; no speculative prefetch is used +under the service search-rate limit. + +The initial model URL must use HTTPS on an official `tone3000.com` host. +Bounded HTTPS redirects to CDN hosts are allowed, while bearer credentials are +sent only to trusted TONE3000 hosts. OpenStudio validates the response, installs +to a durable local path, and preserves source attribution. +OpenStudio does not bulk-download, proxy, or re-host the TONE3000 catalog. + +## TONE3000 partner approval gate + +Before a public release enables the connected catalog, obtain written TONE3000 +approval for the exact release candidate's endpoint scope, OAuth flow, +attribution, creator/license metadata, download behavior, and release wording. +The public build must remain usable with local NAM captures when the connected +service is unavailable or not configured. + +## Release status + +Objective DSP/state/download guards are automated. The remaining release +acceptance is intentionally external or human: + +- authenticated TONE3000 review and a fresh-account end-to-end run; +- a real A1/A2 capture and cabinet-IR download, restart, recovery, and + re-download; +- user audition of the exact build for tone, transition quality, low-buffer + crackle, pedal feel, modulation, delay, reverb, and shimmer; +- a real screen-reader pass over bitmap-backed controls; +- confirmation that the release bundles no third-party captures or IRs by + default; any future starter content requires separate license and + redistribution approval. + +See [NAM and audio QA](testing.md) for the executable checklist. diff --git a/docs/new_features.md b/docs/new_features.md deleted file mode 100644 index df9ee88..0000000 --- a/docs/new_features.md +++ /dev/null @@ -1,408 +0,0 @@ -# Studio13 New Features Implementation Plan - -> **Status:** Historical implementation plan. Several items in this document have since moved from "planned" to implemented or partial. For current public-facing support/feature claims, use `README.md`, `docs/implemented_features.md`, and the mounted UI/actions as the source of truth. - -## Current State Summary - -| Area | Status | Notes | -|------|--------|-------| -| JSFX Audio Processing | Done | YSFX fully integrated, S13FXProcessor wraps scripts as AudioProcessor | -| JSFX @gfx Rendering | Not Started | YSFX header has gfx APIs but never called | -| Lua Scripting (Automation) | Done | 50+ functions in ScriptEngine, transport/tracks/FX/automation/analysis | -| Lua Scripting (GUI) | Not Started | No drawing API, scripts output to console only | -| Theme System | Done | 5 presets + per-color overrides via ThemeEditor, CSS custom properties | -| VST3 Hosting | Done | Full scanning, loading, parameter control, editor windows | -| CLAP Hosting | Partial | CLAPPluginFormat class exists, scan paths configured, loading incomplete | -| LV2 Hosting | Prepared | Code paths exist, JUCE 8 has LV2 support, not compiled in | - ---- - -## Feature 1: Playhead Stop Behavior Setting - -**Priority**: High | **Effort**: Low | **Impact**: High (UX polish) - -Currently the playhead returns to the position where playback started when stopped. Many users (especially those coming from REAPER/Pro Tools) expect it to stop where it is. - -### Implementation Plan - -#### 1.1 Add workspace setting to Zustand store -**File**: `frontend/src/store/useDAWStore.ts` -- Add `playheadStopBehavior: 'return-to-start' | 'stop-in-place'` to store state -- Default: `'return-to-start'` (current behavior, no breaking change) -- Add `setPlayheadStopBehavior(mode)` action -- Persist in project settings / preferences - -#### 1.2 Modify stop action -**File**: `frontend/src/store/useDAWStore.ts` (stop/transport logic) -- In the `stop()` action, check `playheadStopBehavior`: - - `'return-to-start'`: current behavior (set currentTime back to where play started) - - `'stop-in-place'`: leave currentTime at current playhead position -- The C++ backend stop doesn't need changes — the position is a frontend concern - -#### 1.3 Add to Preferences modal -**File**: `frontend/src/components/PreferencesModal.tsx` -- Add toggle under "Editing" or "General" tab: - - Label: "Playhead behavior on stop" - - Options: "Return to start position" / "Stop at current position" - -#### 1.4 Add keyboard shortcut variant -- Consider: pressing Stop once = stop-in-place, pressing Stop twice = return to start - (this is the REAPER convention and feels natural for both workflows) - ---- - -## Feature 2: JSFX @gfx Rendering Support - -**Priority**: Medium | **Effort**: High | **Impact**: High (unlocks community JSFX plugin UIs) - -YSFX already exposes the full @gfx API (`ysfx_gfx_setup`, `ysfx_gfx_run`, `ysfx_gfx_update_mouse`, `ysfx_gfx_add_key`). We just need to render the framebuffer and route input. - -### Architecture Decision - -**Option A**: Render in JUCE native window (like VST3 editors) using `juce::Image` as framebuffer -- Pros: Same window management as VST3, no WebView overhead, direct pixel access -- Cons: Separate native window, not in WebView - -**Option B**: Render in WebView via base64 frame streaming or OffscreenCanvas -- Pros: Integrated in FXChainPanel UI -- Cons: Frame streaming overhead, latency, complex bridge - -**Recommendation**: Option A — native JUCE window, matching existing PluginWindowManager pattern - -### Implementation Plan - -#### 2.1 Create S13FXGfxEditor (JUCE AudioProcessorEditor for JSFX @gfx) -**New file**: `Source/S13FXGfxEditor.h` / `.cpp` - -``` -class S13FXGfxEditor : public juce::AudioProcessorEditor, public juce::Timer -``` - -- Holds a `juce::Image` framebuffer (ARGB, sized to gfx_w x gfx_h) -- On `timerCallback()` (~30fps): - 1. Lock the YSFX effect - 2. Call `ysfx_gfx_setup(effect, &gfxConfig)` with framebuffer pointer, width, height - 3. Call `ysfx_gfx_run(effect)` to execute the @gfx section - 4. Unlock, `repaint()` -- On `paint()`: draw the `juce::Image` to screen -- On `mouseDown/mouseDrag/mouseUp/mouseMove`: call `ysfx_gfx_update_mouse(effect, x, y, buttons)` -- On `keyPressed`: call `ysfx_gfx_add_key(effect, keyCode, modifiers, isPressed)` -- Support `ysfx_gfx_wants_retina()` for HiDPI - -#### 2.2 Configure ysfx_gfx_config_t -**Reference**: `build/_deps/ysfx-src/include/ysfx.h` - -The `ysfx_gfx_config_t` struct needs: -- `pixel_width`, `pixel_height` — framebuffer dimensions -- `pixel_stride` — bytes per row -- `pixels` — pointer to ARGB pixel data (from juce::Image::BitmapData) -- `scale_factor` — for HiDPI (1.0 or 2.0) -- `show_menu` callback — for JSFX scripts that call `gfx_showmenu()` -- `set_cursor` callback — to change mouse cursor - -#### 2.3 Update S13FXProcessor to support editor -**File**: `Source/S13FXProcessor.h` / `.cpp` - -- Change `hasEditor()` to return `true` when the loaded script has a @gfx section - - Use `ysfx_has_section(effect, ysfx_section_gfx)` to detect -- `createEditor()` returns `new S13FXGfxEditor(*this)` when @gfx exists -- Add `ysfx_t* getEffect()` accessor for the editor to call gfx APIs -- Add mutex for gfx thread safety (gfx runs on message thread, audio on audio thread) - -#### 2.4 Wire into PluginWindowManager -**File**: `Source/PluginWindowManager.cpp` - -No changes needed — PluginWindowManager already opens editors via `processor->createEditor()`. Once S13FXProcessor returns an editor, the existing window management works automatically. - -#### 2.5 Handle JSFX image loading (gfx_loadimg) -- YSFX's `gfx_loadimg` loads PNGs from the script's directory -- Need to ensure `ysfx_config_set_data_root()` points to the script's parent directory -- Already set in S13FXProcessor::loadScript — verify it includes the effects directory - -#### 2.6 Testing -- Test with community JSFX plugins that use @gfx: - - ReaEQ (REAPER's built-in EQ with spectrum display) - - Geraint Luff's jsfx-ui-lib examples (knobs, graphs) - - JS: Liteon/analyser (spectrum analyzer) - - Any JSFX with `@gfx` section from the ReaTeam JSFX repository - -### Dependencies -- None (YSFX already compiled and linked) - -### Estimated Sub-tasks -1. Implement S13FXGfxEditor with framebuffer rendering — 1 session -2. Wire mouse/keyboard input routing — 0.5 session -3. Handle gfx_loadimg, show_menu, set_cursor callbacks — 0.5 session -4. Test with 5+ community JSFX scripts — 1 session - ---- - -## Feature 3: Lua Scripting GUI API - -**Priority**: Medium | **Effort**: Medium | **Impact**: Medium (power users can create custom tools) - -Expose a drawing/widget API to Lua scripts so they can create custom tool windows (similar to REAPER's ReaScript gfx.* or ReaImGui). - -### Architecture Decision - -**Option A**: Immediate-mode drawing API (like REAPER's gfx.*) -- `s13.gfx.init("Window Title", 400, 300)` -- `s13.gfx.rect(x, y, w, h)`, `s13.gfx.line(...)`, `s13.gfx.drawstr(...)` -- `s13.gfx.mouse_x`, `s13.gfx.mouse_y`, `s13.gfx.mouse_cap` -- Runs in a loop via `s13.defer(callback)` pattern -- Pros: REAPER-compatible API surface, simple for script authors -- Cons: Need native window + framebuffer per script, C++ rendering - -**Option B**: WebView-based widget API (React components generated from Lua) -- Scripts describe UI declaratively, rendered in a docked WebView panel -- Pros: Rich UI, uses existing React infrastructure, beautiful by default -- Cons: Complex bridge, not REAPER-compatible, higher latency - -**Option C**: ImGui-style API via native window -- `s13.ui.begin("Window")`, `s13.ui.button("Click me")`, `s13.ui.slider("Volume", 0, 1)` -- Backed by JUCE components in a native window -- Pros: Fast, native feel, widget-level API (simpler than pixel drawing) -- Cons: No REAPER compatibility, need widget rendering engine - -**Recommendation**: Option A first (REAPER compatibility), Option C later for convenience widgets - -### Implementation Plan - -#### 3.1 Create S13ScriptWindow (JUCE component for script GUIs) -**New file**: `Source/S13ScriptWindow.h` / `.cpp` - -- `juce::DocumentWindow` subclass with embedded `juce::Component` -- Holds a `juce::Image` framebuffer -- 30fps timer for redraw -- Mouse/keyboard event capture and storage in shared state -- Script reads mouse_x/mouse_y/mouse_cap from Lua globals - -#### 3.2 Add gfx.* API to ScriptEngine -**File**: `Source/ScriptEngine.cpp` - -Register these Lua functions: -``` -s13.gfx.init(title, width, height [, dock]) -- open/resize window -s13.gfx.close() -- close window -s13.gfx.set(r, g, b [, a]) -- set color (0-1) -s13.gfx.rect(x, y, w, h [, filled]) -- rectangle -s13.gfx.line(x1, y1, x2, y2 [, aa]) -- line -s13.gfx.circle(x, y, r [, fill, aa]) -- circle -s13.gfx.arc(x, y, r, ang1, ang2 [, aa]) -- arc -s13.gfx.roundrect(x, y, w, h, radius) -- rounded rect -s13.gfx.drawstr(text [, flags, right, bottom]) -- text -s13.gfx.setfont(size [, face, flags]) -- font -s13.gfx.measurestr(text) -> w, h -- text metrics -s13.gfx.blit(img, scale, rotation, ...) -- image blit -s13.gfx.loadimg(idx, filename) -- load PNG -s13.gfx.getchar() -> keycode -- keyboard input -``` - -Expose as globals (matching REAPER convention): -``` -gfx.x, gfx.y -- current draw position -gfx.w, gfx.h -- window size -gfx.mouse_x, gfx.mouse_y -- mouse position -gfx.mouse_cap -- mouse buttons bitmask (1=L, 2=R, 4=Ctrl, 8=Shift, 16=Alt) -gfx.mouse_wheel -- scroll delta -``` - -#### 3.3 Add s13.defer() for script main loops -**File**: `Source/ScriptEngine.cpp` - -- `s13.defer(callback)` — schedule a Lua function to run on next message loop cycle -- This is the pattern REAPER uses for scripts that need continuous updates -- ScriptEngine maintains a deferred callback queue, processes on a timer - -#### 3.4 Script window management -**File**: `Source/ScriptEngine.cpp` / `MainComponent.cpp` - -- Track open script windows in ScriptEngine -- Close all script windows when script terminates -- Support docking (stretch goal — requires WebView panel integration) - -#### 3.5 Frontend integration -**File**: `frontend/src/components/` (new ScriptRunner panel) - -- Add "Run Script" UI in menu/toolbar -- Script console panel showing print() output -- List of running scripts with stop buttons - -### REAPER API Compatibility Target -The goal is that simple ReaScripts using `gfx.*` functions work with minimal modifications: -- Change `reaper.defer(fn)` to `s13.defer(fn)` -- Change `reaper.GetCursorPosition()` to `s13.getPlayhead()` -- Most `gfx.*` calls should work as-is - -### Estimated Sub-tasks -1. S13ScriptWindow with framebuffer — 1 session -2. Core gfx.* drawing functions (rect, line, circle, text) — 1 session -3. Mouse/keyboard input routing — 0.5 session -4. s13.defer() and script lifecycle — 0.5 session -5. Image loading (gfx.loadimg/blit) — 0.5 session -6. Testing with adapted REAPER scripts — 1 session - ---- - -## Feature 4: Community Compatibility (Themes, Scripts, Extensions) - -**Priority**: Medium | **Effort**: Medium-High | **Impact**: High (ecosystem leverage) - -### 4.1 REAPER Theme Color Import -**Effort**: Low - -REAPER `.ReaperTheme` files are INI-format with 400+ named color entries. We can import the key colors into our CSS custom property system. - -**Plan**: -- Add "Import REAPER Theme" button in ThemeEditor -- Parse the `.ReaperTheme` INI file -- Map REAPER color keys to Studio13 `daw-*` tokens: - - `col_main_bg` -> `--color-daw-dark` - - `col_main_bg2` -> `--color-daw-panel` - - `col_main_text` -> `--color-daw-text` - - `col_tcp_text` -> `--color-daw-text` - - `col_seltrack` -> `--color-daw-selection` - - `col_cursor` -> `--color-daw-accent` - - etc. (partial mapping, best effort) -- Apply as custom theme overrides -- **File**: `frontend/src/components/ThemeEditor.tsx` + new `reaperThemeParser.ts` utility - -### 4.2 REAPER JSFX Compatibility -**Effort**: Already mostly done (audio), high for @gfx - -The YSFX runtime IS REAPER's JSFX engine (same codebase by jpcima). Audio processing is 100% compatible. What's needed: -- Feature 2 above (@gfx rendering) — makes visual JSFX plugins work -- Ensure `@import` and library includes work (YSFX handles this via data_root) -- Test with ReaTeam JSFX repository scripts (largest JSFX collection) - -### 4.3 ReaPack-style Script Distribution (Stretch) -**Effort**: Medium - -ReaPack is REAPER's package manager for scripts/JSFX/themes. Full compatibility is unrealistic, but we can support the same repository format for JSFX/Lua scripts. - -**Plan**: -- Parse ReaPack `.xml` repository index files -- Download and install JSFX scripts to user effects directory -- Download and install Lua scripts to user scripts directory -- Simple browser UI: search, categories, install/update/remove -- **NOT** full ReaPack compatibility (no extensions, no theme installation, no auto-update daemon) -- Focus on the two largest repos: ReaTeam JSFX and ReaTeam Scripts - -### 4.4 CLAP Plugin Hosting (Complete) -**Effort**: Medium - -CLAPPluginFormat already exists. Finish it: -- Complete `createPluginInstance()` implementation -- Parameter mapping (CLAP params -> JUCE AudioProcessorParameter) -- Editor window support (CLAP GUI API) -- State save/restore (CLAP state API) -- Test with popular CLAP plugins (Surge XT, Vital, Dexed CLAP builds) - -### 4.5 LV2 Plugin Hosting (Enable) -**Effort**: Low - -JUCE 8.0.0 has LV2 hosting support. Just needs: -- Add `JUCE_PLUGINHOST_LV2=1` to `target_compile_definitions` in CMakeLists.txt -- Verify scan paths in PluginManager -- Test with a few LV2 plugins -- Handle LV2-specific quirks (turtle files, presets) - -### 4.6 Theme Export Format -**Effort**: Low - -Allow users to export/import Studio13 themes as JSON files: -```json -{ - "name": "My Custom Theme", - "author": "Username", - "version": "1.0", - "colors": { - "daw-dark": "#121212", - "daw-panel": "#1a1a1a", - ... - } -} -``` -- Share via file or paste -- Could host a simple theme gallery on the website - ---- - -## Feature 5: User Theme API (CSS Variables) - -**Priority**: Low | **Effort**: Low | **Impact**: Low (already mostly done) - -The theme system is already user-editable. Small additions: - -### 5.1 Theme file import/export -- Export current theme as `.s13theme` JSON -- Import from file picker -- **File**: `frontend/src/components/ThemeEditor.tsx` - -### 5.2 Additional CSS tokens -Expose more granular tokens for power users: -- `--color-daw-clip-audio` (audio clip background) -- `--color-daw-clip-midi` (MIDI clip background) -- `--color-daw-waveform` (waveform color) -- `--color-daw-grid-line` (timeline grid) -- `--color-daw-playhead` (playhead cursor) -- `--font-daw-primary` (main font family) -- `--font-daw-mono` (monospace font for values) - -### 5.3 Live CSS injection for advanced users -- Allow pasting custom CSS in preferences (textarea) -- Applied as `<style>` tag in head -- For power users who want pixel-perfect control beyond color tokens - ---- - -## Feature 6: C++ Extension API (Future / Low Priority) - -**Priority**: Low | **Effort**: Very High | **Impact**: Low (niche audience) - -This would allow C++ developers to write native extensions (like SWS for REAPER). NOT recommended for near-term roadmap. - -### If implemented: -- Define stable C API (not C++) for extensions to call -- Extension DLLs loaded at startup from `Documents/Studio13/Extensions/` -- API surface: register actions, add menu items, access track/clip data, process audio -- Would need versioned ABI, documentation, example project -- Consider: is this worth it when Lua + JSFX already cover most use cases? - -### Recommendation -Skip this for now. Invest in Lua + JSFX extensibility instead. Revisit when user demand exists. - ---- - -## Implementation Priority Order - -| Phase | Feature | Sessions Est. | -|-------|---------|---------------| -| **Phase 1** | 1. Playhead stop behavior setting | 0.5 | -| **Phase 2** | 2. JSFX @gfx rendering | 3 | -| **Phase 3** | 4.4 Complete CLAP hosting | 2 | -| **Phase 3** | 4.5 Enable LV2 hosting | 0.5 | -| **Phase 4** | 3. Lua scripting GUI API | 4 | -| **Phase 4** | 4.1 REAPER theme import | 1 | -| **Phase 5** | 4.3 ReaPack browser (stretch) | 3 | -| **Phase 5** | 4.6 Theme export/share | 0.5 | -| **Phase 5** | 5. Additional CSS tokens | 0.5 | -| **Future** | 6. C++ Extension API | 10+ | - -Total estimated: ~15 sessions for Phase 1-5, with Phase 1-3 being the highest impact. - ---- - -## Key Files Reference - -| Component | Files | -|-----------|-------| -| YSFX/JSFX runtime | `Source/S13FXProcessor.{h,cpp}`, `build/_deps/ysfx-src/include/ysfx.h` | -| Lua scripting | `Source/ScriptEngine.{h,cpp}` | -| Plugin hosting | `Source/PluginManager.{h,cpp}`, `Source/CLAPPluginFormat.h` | -| Plugin windows | `Source/PluginWindowManager.{h,cpp}` | -| Theme system | `frontend/src/components/ThemeEditor.tsx`, `frontend/src/index.css` | -| Theme store | `frontend/src/store/useDAWStore.ts` (THEME_PRESETS ~line 9871) | -| Transport | `frontend/src/store/useDAWStore.ts` (stop/play actions) | -| Preferences | `frontend/src/components/PreferencesModal.tsx` | -| Native bridge | `frontend/src/services/NativeBridge.ts`, `Source/MainComponent.cpp` | diff --git a/docs/openstudio-website-launch-plan.md b/docs/openstudio-website-launch-plan.md deleted file mode 100644 index 97bc796..0000000 --- a/docs/openstudio-website-launch-plan.md +++ /dev/null @@ -1,704 +0,0 @@ -# OpenStudio Website, Rebrand, and Production Plan - -This document turns the current repo state into a practical launch plan for the OpenStudio website, download flow, rebrand, and production release. - -## 1. Repo Audit Summary - -Based on the current codebase: - -- The app is a desktop DAW with a JUCE C++ backend and a React/TypeScript frontend embedded in a native window. -- The current release flow is still Windows-first. `docs/USER_MANUAL.md` explicitly describes a Windows-native app distributed as a single `Studio13.exe`. -- `build.py` only builds and launches the Windows executable path. -- `CMakeLists.txt` has Apple/Linux compile guards, but `Source/MainComponent.cpp` still hardcodes the WebView2 backend, so macOS/Linux are not production-ready yet. -- There is no real auto-update system in the repo right now. -- Branding is still deeply embedded across product name, executable name, docs, code symbols, file extensions, localStorage keys, scripts, and the SVG wordmark. -- There is a launch blocker around licensing consistency: - - `LICENSE` is AGPLv3. - - `THIRD_PARTY_LICENSES.md` says the project is released under AGPLv3-compatible terms. - - `README.md` still says “Studio13 is proprietary software. All rights reserved.” - -### Branding Surface Area - -Current branding footprint is large even after excluding `node_modules`, generated files, the build folder, and bundled Python: - -- `Studio13`-style product naming: about 200 matches across 42 files -- `S13`-style short prefix and file-format branding: about 146 matches across 29 files - -This is a real migration project, not a single search-and-replace. - -## 2. Website Strategy - -## Goal - -The website should do four jobs: - -1. Explain what OpenStudio is in one screen. -2. Show real screenshots and credible workflow value. -3. Convert visitors into downloads without overpromising unsupported platforms. -4. Make the rebrand feel deliberate and professional. - -## Recommended Positioning - -Use this product framing: - -> OpenStudio is a modern desktop DAW with a native JUCE audio engine and a fast React interface, built for recording, editing, pitch work, mixing, and export without Electron overhead. - -That keeps the core differentiator intact: - -- native desktop audio engine -- modern UI stack -- strong pitch workflow -- plugin hosting -- audio + MIDI + export in one app - -## Recommended Visual Direction - -Do not build a generic SaaS landing page. - -Use a visual language closer to “audio workstation meets technical instrument”: - -- Background: deep ink/navy with layered gradients, subtle gridlines, and spectrogram/waveform textures -- Core colors: - - `#08111b` background - - `#102033` panels - - `#39a0d8` primary brand blue - - `#0cfaf7` highlight cyan - - `#ff9d4d` warm accent for CTA emphasis -- Typography: - - Headlines: `Space Grotesk` or `Sora` - - UI/body: `Manrope` or `IBM Plex Sans` - - Technical labels: `IBM Plex Mono` -- Motion: - - slow waveform reveals - - horizontal screenshot parallax - - meters/spectra that animate once on load, not constantly - -## Recommended Information Architecture - -### Homepage - -1. Hero -2. Proof bar -3. Screenshot-led feature stories -4. Workflow section -5. Plugin and pitch section -6. Download/compatibility section -7. FAQ -8. Roadmap / “coming next” - -### Download Page - -1. Platform cards -2. Install instructions -3. System requirements -4. Checksums / version history -5. Auto-update policy -6. Known limitations - -### Docs / Learn - -1. Getting started -2. Recording -3. MIDI editing -4. Pitch correction -5. Plugin hosting -6. Scripting -7. Export - -## 3. Homepage Wireframe - -## Hero - -Suggested headline: - -> OpenStudio is a native DAW for recording, editing, pitch work, and mixing. - -Suggested subheadline: - -> Built on a JUCE C++ audio engine with a modern React interface, OpenStudio gives you fast editing, deep pitch tools, plugin hosting, and streamlined export in a desktop app that feels immediate. - -Suggested CTAs: - -- `Download for Windows` -- `See Features` -- `Join macOS/Linux waitlist` if those builds are not ready yet - -Hero supporting points: - -- Native audio engine -- Audio + MIDI workflow -- Pitch editing and correction -- VST3 hosting -- Offline export - -## Proof Bar - -Use a short strip directly under the hero: - -- Native desktop app -- JUCE audio engine -- React UI -- Pitch tools -- Multitrack audio + MIDI -- Export to WAV / AIFF / FLAC - -## Feature Story Blocks - -Build these as alternating text + screenshot sections. - -### 1. Record and Arrange - -- Show the main arrange/timeline view -- Copy theme: - - Record audio, arrange clips, split takes, trim edges, work with waveforms, and keep momentum with fast keyboard-driven editing. - -### 2. MIDI and Composition - -- Show piano roll and MIDI track view -- Copy theme: - - Create MIDI tracks, edit notes in the piano roll, use the virtual keyboard, and keep timing tight with snap and quantize tools. - -### 3. Pitch Workflow - -- Show pitch editor / pitch corrector UI -- Copy theme: - - Analyze vocal or melodic material, adjust notes visually, refine formants and transitions, or use real-time pitch correction inside the FX workflow. - -### 4. Mix and Finish - -- Show mixer + FX chain + render/export -- Copy theme: - - Shape the mix with channel strips, automation, plugin chains, built-in effects, and export-ready rendering options. - -## Workflow Section - -Three cards: - -- Record -- Edit -- Release - -Each card should show 3-4 concrete capabilities instead of generic claims. - -## Download Section - -Use honest platform states: - -- Windows: available first -- macOS: planned / experimental until verified -- Linux: planned / experimental until verified - -Do not label all three as “available now” unless CI, packaging, and QA are actually ready. - -## 4. Feature List for Website Copy - -Below is the recommended product copy set based on what exists in the repo today. This is split into: - -- features safe to market once verified in QA -- features that should be labeled beta, experimental, or “coming soon” until validated - -## A. Safe to Market First - -These are strong candidates for the main website once we confirm them in a release checklist. - -### Native multitrack recording and playback - -- What it does: lets users record and play back multiple tracks with a native desktop audio engine -- How to use it: create tracks, arm recording, choose inputs, and record directly into the timeline -- Why it matters: this is the core DAW workflow and should anchor the homepage - -### Audio timeline editing - -- What it does: arrange clips, trim, split, move, loop, zoom, and work directly with waveforms -- How to use it: import audio, drag clips on the timeline, use snap/grid controls, and split at the playhead -- Why it matters: this is one of the clearest screenshot-friendly workflows - -### MIDI tracks, piano roll, and virtual keyboard - -- What it does: supports MIDI input, clip editing, note editing, and on-screen keyboard input -- How to use it: create a MIDI track, open the piano roll, draw or edit notes, and use quantize when needed -- Why it matters: shows the app is not audio-only - -### Mixer, channel strips, and metering - -- What it does: gives track-level balance control with faders, pan, mute/solo, and meters -- How to use it: open the mixer or track controls, set levels, pan positions, and monitor signal activity in real time -- Why it matters: important for credibility with DAW users - -### Built-in effects and FX chains - -- What it does: supports per-track input FX, track FX, and master FX, plus built-in processors -- How to use it: open the FX chain, add built-in effects or plugins, then reorder or bypass processors -- Why it matters: this gives clear “record, process, mix” messaging - -### Pitch editing and correction - -- What it does: offers graphical pitch editing plus a real-time pitch corrector workflow -- How to use it: analyze a clip, open the pitch editor, move notes or use correction controls for faster cleanup -- Why it matters: this is one of the product’s strongest differentiators - -### Automation lanes - -- What it does: lets users automate volume, pan, and parameter changes over time -- How to use it: show automation on a track, add control points, then shape the curve during playback or editing -- Why it matters: a serious DAW needs visible automation support - -### Render and export - -- What it does: exports finished work to common audio formats like WAV, AIFF, and FLAC -- How to use it: open the render/export flow, pick format and depth, then render the project or target range -- Why it matters: this completes the “from recording to export” story - -### Lua scripting and workflow automation - -- What it does: exposes scripting hooks for transport, tracks, FX, and automation workflows -- How to use it: open the script editor, write or run scripts, and automate repetitive tasks -- Why it matters: strong USP for advanced users, but should sit lower on the homepage - -## B. Market Only After Verification - -These are interesting differentiators, but they should be validated before they appear prominently on the website. - -### ARA plugin hosting - -- Good USP if stable -- Needs explicit regression testing before public marketing - -### Stem separation - -- Strong headline feature -- Needs packaging validation because it depends on Python/model/runtime setup - -### DDP export - -- Valuable for mastering users -- Needs end-to-end QA before public claims - -### Theme import/export - -- Nice differentiator -- Should be verified so we do not oversell customization depth - -### Command palette, screensets, batch conversion, cleanup tools - -- Great supporting features -- Better for a features page than the hero - -### Video sync and surround workflows - -- Present in code paths and UI state -- Should not be advertised until tested and production-ready - -### CLAP / LV2 support - -- Present in the codebase -- Treat as beta/experimental until scan/load/editor behavior is verified - -## 5. Screenshot Plan - -The current `Design screenshots/` folder is not enough for marketing. Most of those images are menu captures, not hero-grade product screenshots. - -### Capture These Fresh - -1. Main timeline with multiple tracks and waveforms -2. Mixer with active meters -3. Piano roll with notes and velocity lane -4. Pitch editor with visible note blobs/curves -5. FX chain + plugin browser -6. Render/export dialog -7. Theme editor or scripting view for the “power users” section - -### Existing Assets Worth Reusing - -- `design.png` -- `logo_preview.png` -- `icon_preview.png` -- `Design screenshots/midi track and clip.png` -- `Design screenshots/render-export.png` - -## 6. Download and Installer Strategy - -## Current State - -Right now the project is closest to this distribution model: - -- Windows executable build output -- copied runtime assets beside the executable -- no formal installer -- no update channel - -That matches the current manual as well. - -## What To Ship First - -Recommendation: - -1. Ship Windows first -2. Add a real installer -3. Add signed release artifacts and checksums -4. Add updater support after installer packaging is stable -5. Bring macOS next -6. Bring Linux after macOS if bandwidth is limited - -## Windows - -### Current Build Output - -Current production build command: - -- `python build.py prod` - -Current output path: - -- `build/Studio13_v2_artefacts/Release/Studio13.exe` - -### What the Windows Installer Must Include - -- main executable -- WebView2 requirement handling -- `ffmpeg.exe` -- `effects/` -- `scripts/` -- `models/` -- bundled Python/runtime if stem separation depends on it -- app icon, version metadata, uninstaller - -### Recommended Installer Approach - -Use one of these: - -- Fastest path: Inno Setup -- More enterprise-heavy path: WiX -- Microsoft-store-style path: MSIX - -Recommendation: start with Inno Setup because it is the shortest path to a professional Windows installer. - -## macOS - -### Feasibility - -Possible in theory from the CMake guards, but not ready to promise publicly yet. - -Current blocker: - -- `Source/MainComponent.cpp` hardcodes the WebView2 backend instead of selecting the browser backend per platform - -### What Must Happen Before macOS Download Exists - -- fix platform-specific webview backend selection -- build on macOS hardware/CI -- package app resources inside the `.app` bundle -- sign with Apple Developer ID -- notarize -- package as `.dmg` - -### What the macOS Bundle Must Include - -- `.app` bundle -- resources inside `Contents/Resources` -- models/effects/scripts -- ffmpeg binary -- any Python/runtime dependencies if required - -## Linux - -### Feasibility - -Possible later, but currently not production-ready. - -### Minimum Viable Linux Release - -- tarball or AppImage first -- `.deb` later if needed - -### Linux Requirements To Solve - -- webview backend validation -- ALSA/JACK/WebKitGTK dependency strategy -- asset bundling -- plugin scanning differences -- distro testing - -Recommendation: if Linux happens, AppImage is the cleanest first public artifact. - -## 7. Auto-Update Support - -## Current State - -There is no updater implementation in the repo right now. - -No evidence found for: - -- WinSparkle -- Sparkle -- Squirrel -- appcast/update feeds -- update signatures -- check-for-updates UI -- background patching - -## Recommendation - -### Windows - -- Use WinSparkle if you want native desktop-style update checks -- Publish signed installers plus an appcast feed - -### macOS - -- Use Sparkle -- Pair with code signing + notarization from day one - -### Linux - -- Avoid promising true in-app auto-update at first -- Prefer package-manager updates or AppImageUpdate only if you commit to AppImage - -## Important Timing Note - -Do the OpenStudio rename before shipping the first public auto-update channel. - -If you launch updates under `Studio13` and then rename immediately after, you create avoidable migration complexity for: - -- appcast/feed URLs -- code signing identity -- install directories -- update channels -- support docs - -## 8. Rebrand Plan: Studio13 -> OpenStudio - -## Critical Warning: Do Not Blindly Replace `S13` With `OS` - -This needs one explicit naming decision before implementation. - -### Why `OS` Is Dangerous Technically - -- In Lua, `os` is already a standard library namespace. -- A project extension like `.os` is too generic and confusing. -- `OS` also reads as “operating system” in code, docs, and support discussions. - -### Recommended Rule - -- Customer-facing short label: `OS` is okay in visual marketing copy only -- Technical/product identifiers: use `OpenStudio` or `openstudio`, not raw `OS` - -## Recommended Naming Map - -- Product name: `Studio13` -> `OpenStudio` -- App target / executable: `Studio13_v2` / `Studio13.exe` -> `OpenStudio` -- Internal class prefix: - - `S13PitchCorrector` -> `OpenStudioPitchCorrector` - - `S13FXProcessor` -> `OpenStudioFXProcessor` - - `S13FXGfxEditor` -> `OpenStudioFXGfxEditor` - - `S13ScriptWindow` -> `OpenStudioScriptWindow` -- Scripting namespace: - - do not use `os.*` - - use `openstudio.*` or `ostudio.*` - - keep `s13.*` as a deprecated compatibility alias for one transition period - -## File Extension Recommendations - -Avoid `.os`. - -Recommended safer replacements: - -- project file: `.s13` -> `.ostudio` or `.osproj` -- peak cache: `.s13peaks` -> `.ostudiopeaks` -- theme file: `.s13theme` -> `.ostheme` -- preset file: `.s13preset` -> `.ospreset` - -My recommendation: - -- `.ostudio` for projects -- `.ostheme` for themes -- `.ospreset` for presets -- `.ostudiopeaks` for cache files - -## Branding Surfaces To Rename - -### Product and build identity - -- CMake project and target names -- `PRODUCT_NAME` -- executable name -- macOS bundle name -- installer product name -- package.json name where user-visible -- manifest names and metadata - -### UI and docs - -- README -- manual -- API docs -- About dialogs -- onboarding text -- plugin browser author strings -- keyboard shortcut modal -- window titles -- support links - -### File formats and runtime keys - -- project extension -- theme/preset/cache extensions -- localStorage keys -- saved templates keys -- screenshots/workflow exports -- app data folder paths -- documents paths -- debug log file paths - -### Code symbols and filenames - -- `S13*` classes/files -- `s13_` localStorage keys and constants -- `Studio13Application` -- `Studio13_Debug.log` - -### Plugin and scripting branding - -- built-in FX names -- “S13FX” category naming -- scripting APIs -- script templates/comments - -## Logo Work - -The current `frontend/public/logo.svg` is not simple text. The wordmark is made from vector paths. - -That means the clean path is: - -1. re-export the logo from the original design source if available, or -2. replace the lower wordmark area with a new `OpenStudio` vector wordmark - -Do not treat it like an editable text layer unless you have the original source file. - -## Backward Compatibility Plan - -To avoid breaking existing users/projects: - -- continue reading old `.s13` project files for at least one full transition cycle -- continue reading old theme/preset/cache formats where practical -- support old `s13.*` Lua aliases temporarily if scripts already exist -- migrate old app data paths to new `OpenStudio` paths on first launch - -## 9. Production TODO List - -## Phase 0: Decisions - -- Decide final project file extension -- Decide whether scripting namespace becomes `openstudio.*` or stays `s13.*` with aliasing -- Decide license position: AGPL/commercial/proprietary must be made consistent -- Decide first public platform: recommended `Windows first` -- Decide whether CLAP/LV2/video/DDP are launch features or post-launch features - -## Phase 1: Rebrand Foundations - -- Rename product name in build system, manifests, app metadata, and docs -- Replace the SVG wordmark with `OpenStudio` -- Rename app data folders and document paths -- Rename visible FX labels from `S13` to `OpenStudio` or `OS FX` -- Rename file formats with backward-compatible import support -- Rename localStorage keys with migration logic -- Add compatibility shims for old script/file naming where needed - -## Phase 2: Product Hardening - -- Create a release checklist for audio recording, playback, save/load, render, plugin scan/load, pitch edit, MIDI edit, and export -- Validate all “hero” website features manually -- Mark unstable features as beta or hide them -- Test large projects, missing media handling, and plugin crash scenarios -- Test sample rate changes and audio device switching -- Add crash logging and cleaner error reporting -- Add safe mode / recovery mode for plugin-related startup issues if not fully ready yet - -## Phase 3: Packaging and Distribution - -- Build a proper Windows installer -- Add version metadata and signed binaries -- Add release artifact checksums -- Bundle all required runtime assets consistently -- Verify clean install and uninstall on a new machine -- Define where logs, user data, scripts, models, and themes live after install - -## Phase 4: Cross-Platform Readiness - -- Remove hardcoded WebView2 backend usage from shared code -- validate browser backend selection per platform -- stand up macOS build pipeline -- stand up Linux build pipeline -- test plugin hosting and file dialogs on each platform -- package runtime assets correctly for `.app` and AppImage/tarball layouts - -## Phase 5: Auto-Update - -- Choose updater stack per platform -- Generate signed update feed -- create stable/beta channels -- add update settings UI -- test update path from one released version to the next -- test rename-era migration if updater arrives after rebrand work begins - -## Phase 6: Website Production - -- Design homepage and download page -- Capture polished screenshots -- write short, honest feature copy -- add platform matrix and system requirements -- add release notes/changelog page -- add FAQ for plugins, audio drivers, supported formats, and project compatibility -- add privacy/support/contact pages - -## Phase 7: Legal and Compliance - -- Resolve the AGPL vs proprietary contradiction -- confirm third-party redistribution rights for FFmpeg, WebView2, ASIO handling, models, and any bundled Python dependencies -- publish third-party notices in installer or app bundle if required -- add EULA/privacy policy only if they match the actual license strategy - -## Phase 8: Launch Operations - -- create versioning policy -- create release notes template -- create bug-report template -- create support email/contact flow -- create “known issues” page -- create telemetry/privacy stance -- decide whether beta releases are public or invite-only - -## 10. Recommended Public Launch Sequence - -Best path from the current repo: - -1. Finalize branding decisions for OpenStudio naming and file extensions -2. Resolve licensing position -3. Finish the Windows-only production release path -4. Launch website with Windows download first -5. Mark macOS and Linux as “in progress” or “join waitlist” -6. Add updater support after installer and signing are stable -7. Bring macOS next -8. Bring Linux after platform validation - -## 11. What Should Not Be Promised on Day One - -Until validated, avoid homepage claims like: - -- “Available on Windows, macOS, and Linux” -- “Automatic updates included” -- “Full CLAP/LV2 support” -- “Production-ready video workflow” -- “Mastering-grade DDP workflow” - -These may become true, but they should not headline the site until verified. - -## 12. Recommended Immediate Next Actions - -If we want the fastest path to a credible public launch, do these next: - -1. Lock the final OpenStudio naming rules, especially file extensions and scripting namespace. -2. Resolve the license mismatch across `LICENSE`, `README.md`, and release messaging. -3. Decide the Windows installer format and build the first signed installer. -4. Capture fresh hero-quality screenshots from the actual app. -5. Build the marketing site around a Windows-first release, with macOS/Linux shown as upcoming unless proven ready. diff --git a/docs/pitch_corrector_feat_plan.md b/docs/pitch_corrector_feat_plan.md deleted file mode 100644 index 33b5e67..0000000 --- a/docs/pitch_corrector_feat_plan.md +++ /dev/null @@ -1,139 +0,0 @@ -# Studio13 Pitch Correction Engine — Signalsmith Stretch Integration Plan - -## Goal - -Replace the broken custom SMS synthesis engine with **Signalsmith Stretch** — a free -(MIT), header-only, production-quality phase vocoder that handles stereo natively, supports -per-block varying pitch ratios, and has built-in formant compensation. - -The **note detection and graphical editor** (blobs, contour display, note segmentation) are -kept as-is using `PitchAnalyzer` (YIN) and `PartialTracker` / `SinusoidalModel::analyze()`. -Only the **synthesis** (actual audio pitch shifting) is replaced. - ---- - -## Architecture After This Change - -``` -Input Audio (stereo or mono) - │ - ▼ -buildCorrectionCurve() ← per-sample pitch ratio from note edits -buildFormantCurve() ← per-sample formant ratio (independent shift) - │ - ▼ -SignalsmithShifter::process() ← replaces ALL of: SMS resynthesis, WORLD vocoder, - │ presetDefault(ch, sr) phase vocoder, mono mix + SGT stereo path - │ process in intervalSamples blocks - │ setTransposeFactor(avgRatio) per block - │ setFormantFactor(1/avgRatio) per block (keeps formants in place) - │ handles stereo natively — NO mono mix needed - │ - ▼ -Output Audio (same channel count as input) -``` - ---- - -## What Is Removed - -| File | Why Removed | -|------|-------------| -| `Source/FormantPreserver.h/.cpp` | WORLD vocoder — mono-only, speech-oriented, replaced | -| `Source/PitchShifter.h/.cpp` | Phase vocoder — basic, no formant preservation, replaced | -| `Source/SpectralPitchShifter.h/.cpp` | Old spectral pitch shifting, not used | -| `Source/SpectralProcessor.h/.cpp` | STFT utils only used by SpectralPitchShifter | -| `Source/PolyResynthesizer.h/.cpp` | Polyphonic SMS resynthesis, replaced | -| `Source/HarmonicMaskGenerator.h/.cpp` | Wiener masks for poly, replaced | -| SMS synthesis from `SinusoidalModel` | `resynthesizeWithRatios`, `additiveSynthOLA`, `extractSpectralResidual`, `synthesizeStochasticResidual`, `extractTransientLayer`, `classifyVoiceQuality`, `warpEnvelope`, `computeAmplitudeEnvelope`, `resynthesize` | -| SMS cache from `PitchResynthesizer` | `cachedAnalysis_`, `cachedGroups_`, `cachedCorrectedMono_`, `cachedRatios_`, `findChangedRange()` | - -## What Is Kept - -| File | Why Kept | -|------|----------| -| `Source/PitchAnalyzer.h/.cpp` | YIN pitch detection — drives the graphical editor note blobs | -| `Source/PitchDetector.h/.cpp` | Low-level YIN algorithm | -| `Source/PitchMapper.h/.cpp` | Scale/key snapping for real-time corrector | -| `Source/PartialTracker.h/.cpp` | STFT + partial tracking — kept for future SMS analysis improvements | -| `Source/SinusoidalModel.h/.cpp` | `analyze()` + `groupPartials()` — kept for future note visualization | -| `Source/S13PitchCorrector.h/.cpp` | Real-time inline pitch corrector (auto-tune style FX) | -| `Source/PolyPitchDetector.h/.cpp` | Basic-Pitch ONNX model — polyphonic note detection | -| `Source/PitchResynthesizer.h/.cpp` | Refactored: keeps `buildCorrectionCurve()`, uses Signalsmith | -| `Source/ARAHostController.h/.cpp` | ARA plugin hosting — unrelated to pitch | -| `Source/StemSeparator.h/.cpp` | AI stem separation — unrelated to pitch | - ---- - -## New File - -### `thirdparty/signalsmith/signalsmith-stretch.h` -- Single header from https://github.com/Signalsmith-Audio/signalsmith-stretch -- MIT license -- Provides `signalsmith::stretch::SignalsmithStretch<float>` - -### `Source/SignalsmithShifter.h` -``` -SignalsmithShifter::process( - input, // const float* const* — one pointer per channel - numChannels, - numSamples, - sampleRate, - ratios, // per-sample pitch ratio (1.0 = no shift) - formantRatios // per-sample formant ratio (empty = auto-preserve) -) -> vector<vector<float>> // one vector per channel, exactly numSamples long -``` -Internally: -- Creates `SignalsmithStretch<float>`, calls `presetDefault(numChannels, sampleRate)` -- Processes in `intervalSamples()`-sized blocks -- Per block: averages ratio, calls `setTransposeFactor()` + `setFormantFactor()`, calls `process()` -- Feeds silence to flush latency, trims output to exact `numSamples` - ---- - -## Quality Characteristics - -| Aspect | Before (broken SMS) | After (Signalsmith) | -|--------|---------------------|---------------------| -| Stereo | Crashes | Native stereo, no mono mix | -| Amplitude | Distorted (10× inflation) | Preserved | -| Formants | Broken LPC path | Built-in compensation | -| Sibilants | Smeared by STFT | Good (Signalsmith preserves transients) | -| Artifacts | Heavy (phase vocoder + additive resynth combined) | Clean | -| Large shifts | Crashes or distorts | Handles well | - ---- - -## Implementation Steps - -1. Add `signalsmith-stretch.h` to `thirdparty/signalsmith/` -2. Create `Source/SignalsmithShifter.h` (wrapper class) -3. Refactor `Source/PitchResynthesizer.cpp` — replace SMS block with `SignalsmithShifter::process()` -4. Remove synthesis methods from `Source/SinusoidalModel.cpp/.h` -5. Delete `FormantPreserver`, `PitchShifter`, `SpectralPitchShifter`, `SpectralProcessor`, `PolyResynthesizer`, `HarmonicMaskGenerator` -6. Update `CMakeLists.txt` — add `thirdparty/signalsmith` to include path, remove deleted files, remove `world_static` dependency -7. Build, fix errors - ---- - -## Future Improvements (Post-Integration) - -Once the pitch corrector works cleanly with Signalsmith, the next quality improvements are: - -### Near term -- **Per-note formant control**: The `buildFormantCurve()` already builds a per-sample formant - ratio. Wire it to `setFormantFactor()` per block for independent formant shifting per note. -- **Transition crossfades**: At note boundaries (voiced→unvoiced), blend ratio smoothly over - ~10ms already handled by `buildCorrectionCurve()`. - -### Medium term -- **Incremental re-correction**: Signalsmith is fast enough to re-process the full window - on each edit (~50ms for a 5s window). No incremental patching needed. -- **SMS analysis for pitch display**: `PartialTracker` + `SinusoidalModel::analyze()` can be - used to display richer note information (harmonic content, detected partials) in the pitch - editor UI beyond what YIN gives. - -### Long term -- **Back to SMS synthesis**: Once a stable reference output exists from Signalsmith, we can - incrementally improve the SMS synthesis layer and A/B test against Signalsmith until SMS - surpasses it. At that point, SMS becomes the default and Signalsmith becomes the fallback. diff --git a/docs/pitch_editor_engine_v2_implementation_plan.md b/docs/pitch_editor_engine_v2_implementation_plan.md deleted file mode 100644 index 2378d7d..0000000 --- a/docs/pitch_editor_engine_v2_implementation_plan.md +++ /dev/null @@ -1,473 +0,0 @@ -# Pitch Editor Engine V2 Implementation Guide - -Status note on 2026-04-17: - -- the engine-v2 implementation work is complete enough for comparison, but it is no longer the recommended active recovery path -- root-cause research, ML benchmark, and engine-v3 feasibility are now recorded in: - - [pitch_root_cause_research_20260417.md](c:/Users/srvds/Documents/Codes/Studio13-v3/docs/pitch_root_cause_research_20260417.md) -- current repo truth: - - `pitch_only_adaptive_selector` remains the kept working baseline - - local ML restoration is blocked because no materially stronger note-local restorer is available in this environment - - the first `engine-v3` feasibility probe returned `stop` - -## Goal -Move the pitch editor to a 2-tier workflow: - -- `Tier 1`: instant drag monitoring while the user is moving a note -- `Tier 2`: debounced HQ note/island render that becomes the authoritative playback result for the changed region - -This program is built on top of the current repo truth: - -- kept live branch: `pitch_only_adaptive_selector` -- active experimental path: `pitch_only_engine_v2_program` -- note-change stutter and formant drift are still unresolved -- code must stay in place for user audition before cleanup - -## Locked Product Behavior -- Use `2` tiers, not 1 or 3 -- Drag monitoring starts immediately while the note is moving -- HQ note render starts after roughly `300 ms` of inactivity -- Transport playback does not use preview-quality audio for committed edits -- Changed regions wait for HQ-ready cache audio before the new edit becomes authoritative -- Experimental code is not removed until after user listening validation - -## Implemented Foundation -### Current v1 infrastructure -- Added `note_hq` as a first-class pitch correction render mode -- Auto-apply now targets note-local HQ renders instead of staged playhead segments -- Drag lifecycle now starts and stops interactive pitch monitoring explicitly -- Playback region replacement keeps the last valid rendered region until a newer overlapping HQ region is ready -- Regression harness accepts `note_hq` render jobs -- Added a dedicated RAM scrub-preview path: - - backend entrypoints: - - `startPitchScrubPreview` - - `updatePitchScrubPreview` - - `stopPitchScrubPreview` - - native playback mixes a RAM loop monitor voice directly from `PlaybackEngine` - - scrub loops are extracted from the stable interior of the selected note, not the onset - - stereo clips are supported by deriving loop bounds from mono analysis and extracting matching multichannel audio -- Added the first real `engine-v2` audio implementation on top of the scaffold: - - Signalsmith-based voiced-core renderer in the transition window - - cepstral envelope restoration on voiced-support frames - - spectral-flatness-driven transient/unvoiced bypass mask - - residual carry reinjection from shared own-engine analysis - - transition compositor that mixes: - - original transient shell - - voiced core - - adaptive-selector base - - residual carry - -### Current limitations -- The scrub voice is now benchmarked through a dedicated stopped-transport scrub harness, but it still needs real user audition in the editor UI -- `engine-v2` sound work is now implemented, but the first full audible pass regresses both primary `+4` truth clips badly -- The current `engine-v2` transition compositor is still too broad and is dragging the stable note body off target -- The audible product issues remain unresolved: - - stutter on note change - - formant/timbre drift on note change - -## Architecture -### Tier 1: drag monitoring -- Start on note drag begin for pitch-affecting gestures -- Use note-local pitch preview derived from the selected note window -- Clear immediately on drag end -- This tier is intentionally fast and temporary - -### Tier 2: HQ note cache -- Trigger after debounce -- Render only the changed note/island region plus transition shoulders -- Store the result as a playback override region -- Replace overlapping cached regions atomically when a newer HQ render finishes - -### Transport behavior -- While a new HQ note render is pending, transport keeps using the last valid audio for that region -- Once the new HQ note render completes, playback swaps to the new override region -- No preview-quality renderer is used for committed transport playback - -## Next Engine-V2 Audio Steps -1. Tighten `engine-v2` engagement to the transition core instead of the wider note window -2. Lower wet exposure so adaptive-selector remains the dominant carrier outside the transition nucleus -3. Keep cepstral envelope restoration only where voiced support is strong and stable -4. Keep transient/unvoiced bypass mandatory at the transition edges -5. Rebalance residual carry so it restores breathiness without shifting core pitch/body -6. Re-run the truth cases against: - - `CTRL-SHIP` - - `CTRL-R6` - - `pitch_only_adaptive_selector` - - current `pitch_only_engine_v2_program` - -## Current Benchmark Snapshot -### Dedicated scrub preview -- Implemented and build-clean -- Native/backend path is active -- Dedicated scrub regression now passes with the natural-segment path on stopped transport: - - run: `20260416_200227_pitchOrg_scrub_preview_r8` - - result: - - `scrubPreviewAudible=true` - - `scrubPreviewFirstDragAudible=true` - - start latency `26.9 ms` - - stop latency `26.8 ms` - - loop duration `240.0 ms` - - base pitch `365.17 Hz` - - repeat stability `0.4699` - - last peak `0.1123` -- The render-quality harness still does not score drag feel, so scrub regression remains a separate check -- Multi-scenario scrub suite now exists: - - runner: `tools/run-ui-pitch-scrub-suite.ps1` - - richer runs: - - `20260416_231821_pitchOrg_scrub_suite_richer_r1` - - `20260416_234516_pitchTest_scrub_suite_richer_r1` - - current measured state: - - first drag audible: `true` - - repeated-drag suite case audible: `true` - - after-transport-cycle suite case audible: `true` - - true multi-note fixtures now exist: - - `tests/fixtures/pitch-regression/example_pitchOrg_scrub_multinote.json` - - `tests/fixtures/pitch-regression/example_pitchTest_scrub_multinote.json` - - multi-note scrub run: `20260416_235640_pitchTest_scrub_suite_multinote_r3` - - selection-change is now exercised end-to-end on the multi-note scrub fixture -- Important honesty note: - - scrub preview is now structurally present and richer-benchmarked across the current canonical local scrub scenarios - - `H1` is complete for the current canonical local fixture corpus - - the suite does not yet score perceived “breaking/tearing” quality directly - - the user-facing sound still needs listening validation in the editor - -### Boundary / transient / formant harnesses -- Boundary suite exists and runs: - - runner: `tools/run-ui-pitch-boundary-suite.ps1` -- Manifest-driven regression suite runner now exists: - - `tools/run-ui-pitch-regression-suite.ps1` -- Transient and formant wrappers now exist on top of it: - - `tools/run-ui-pitch-transient-suite.ps1` - - `tools/run-ui-pitch-formant-suite.ps1` -- Richer manifests added: - - `tests/fixtures/pitch-regression/suites/transient_richer_suite.json` - - `tests/fixtures/pitch-regression/suites/formant_richer_suite.json` -- Richer benchmark runs: - - transient: `20260416_231844_transient_suite_richer_r1` - - formant: `20260416_233426_formant_suite_richer_r1` -- Important honesty note: - - `H3` and `H4` are now complete for the current canonical local fixture corpus - - they still do not represent a broad real-world vocal corpus, but they are no longer smoke-only placeholders - -## Remaining Iterations -- Mandatory iterations still remaining: `0` -- Conditional phase-locking iterations still remaining: `+2` -- Why `0` remains: - - `S1-S3` are effectively in - - `H2` boundary harness is in - - `H1`, `H3`, and `H4` now have richer canonical-suite coverage and are treated as closed for the current local fixture corpus - - the adaptive-carrier tuning track is closed for now at its current best profile - - `A7` is complete and flat - - the engine-v2 challenger is now frozen after the narrowed `r8` close-out pass - - there is no remaining mandatory harness close-out work in the current local fixture corpus - -## What We Learned After Close-Out -- engine-v2 did not fail because of missing plumbing anymore -- it failed because the remaining error is centered on: - - transition ownership and boundary timing drift - - mixed transient and first-voiced-cycle handling - - formant preservation that is too weak and too local for hard transitions -- those findings came from the bounded root-cause pass on 2026-04-17, not from another speculative renderer loop -- that means this document should now be read as implementation history and available scaffolding, not as the primary next-step plan - -### `engine-v2` first audible implementation -- `pitchOrg +4` - - branch: `pitch_only_engine_v2_program` - - run: `20260416_124252_pitchOrg_plus4_note_hq_engine_v2_program_impl_r2` - - result: - - note mel `9.653` - - env `1.782` - - note/body/core cents `-18.72 / -18.72 / -37.23` - - entry mel `10.430` - - exit mel `11.219` - - onset artifact `3.23` - - `spectralEnvelopeCorrectionUsed=true` - - `engineV2Used=true` -- `pitchTestOrg +4` - - branch: `pitch_only_engine_v2_program` - - run: `20260416_124622_pitchTestOrg_plus4_note_hq_engine_v2_program_impl_r3` - - result: - - note mel `11.303` - - env `1.425` - - note/body/core cents `-17.94 / -17.94 / -36.45` - - entry mel `8.389` - - exit mel `10.274` - - onset artifact `0.85` - - `spectralEnvelopeCorrectionUsed=true` - - `engineV2Used=true` -- Verdict: - - infrastructure is real and benchmarkable - - first audible pass is not keepable - - code remains in place for user test and further tuning - -### Latest tuning snapshot -- `B1-B3` engine-v2 challenger close-out - - runs: - - `20260416_230357_enginev2_narrow_pitchOrg_plus4_r8` - - `20260416_230555_enginev2_narrow_pitchTest_plus4_r8` - - result: - - `pitchOrg +4` - - note mel `6.608` - - env `1.009` - - entry `7.620` - - exit `7.112` - - onset artifact `1.390` - - formant drift `0.395` - - `pitchTestOrg +4` - - note mel `3.143` - - env `0.498` - - entry `7.017` - - exit `1.887` - - onset artifact `4.000` - - formant drift `0.062` - - verdict: - - the narrowed/drier engine-v2 pass still loses clearly to the adaptive correction carrier on the hard clip - - easy-clip onset improves, but entry timbre still worsens - - hard-clip entry and onset remain much worse than the adaptive carrier - - freeze the challenger and stop spending main budget here - -- `A7` STFT correction-layer sweep - - runs: - - `20260416_224905_adaptive_stft_tune_r10_1024o8` - - `20260416_224905_adaptive_stft_tune_r10_2048o8` - - `20260416_224905_adaptive_stft_tune_r10_2048o4` - - result: - - all three profiles were effectively identical on both smoke cases - - `pitchOrg +4` stayed at about: - - note mel `6.525` - - env `1.022` - - entry `6.885` - - exit `7.112` - - onset artifact `2.257` - - formant drift `0.374` - - `pitchTestOrg +4` stayed at about: - - note mel `2.828` - - env `0.469` - - entry `1.703` - - exit `1.887` - - onset artifact `1.688` - - formant drift `0.050` - - verdict: - - STFT size / hop is not the dominant lever in the current adaptive correction layer - - `A7` should be treated as complete and flat - -- `pitchOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_223535_adaptive_residual_tune_r9_dry` - - result: - - note mel `6.525` - - env `1.022` - - entry mel `6.885` - - exit mel `7.112` - - onset artifact `2.257` - - boundary timing error `8.417 ms` - - formant body harmonic drift `0.374` - - `spectralEnvelopeCorrectionUsed=true` -- `pitchTestOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_223535_adaptive_residual_tune_r9_dry` - - result: - - note mel `2.828` - - env `0.469` - - entry mel `1.703` - - exit mel `1.887` - - onset artifact `1.688` - - boundary timing error `2.229 ms` - - formant body harmonic drift `0.050` - - `spectralEnvelopeCorrectionUsed=true` -- Verdict on the current best adaptive-carrier correction pass: - - `r9 dry` is the current best adaptive correction profile - - `A5` cepstral strength/lifter sweeps were effectively flat on the smoke suite - - `A6` residual carry sweep favored the driest profile, so the default correction path now keeps residual reinjection off - - it is still not fully keepable: - - `pitchOrg +4` onset artifact remains materially worse than plain adaptive - - `pitchTestOrg +4` onset and exit remain somewhat worse than plain adaptive even though the gap is much smaller - - the next tuning priority should now be: - - `A7` STFT correction-layer sweep - - then challenger close-out `B1-B4` - -- `pitchOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_213810_adaptive_boundary_tune_r7_onsetcleanup` - - result: - - note mel `6.526` - - env `1.023` - - entry mel `6.890` - - exit mel `7.112` - - onset artifact `2.261` - - boundary timing error `8.396 ms` - - formant body harmonic drift `0.374` - - `spectralEnvelopeCorrectionUsed=true` -- `pitchTestOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_213810_adaptive_boundary_tune_r7_onsetcleanup` - - result: - - note mel `2.828` - - env `0.470` - - entry mel `1.707` - - exit mel `1.887` - - onset artifact `1.714` - - boundary timing error `2.250 ms` - - formant body harmonic drift `0.050` - - `spectralEnvelopeCorrectionUsed=true` -- Verdict on the current best adaptive-carrier correction pass: - - `r7` is the best adaptive correction profile so far - - it meaningfully improves the hard clip: - - note mel is now close to the plain adaptive baseline - - exit damage is far lower than earlier correction passes - - it is still not fully keepable: - - `pitchOrg +4` onset artifact remains materially worse than plain adaptive - - `pitchTestOrg +4` onset artifact and exit are still worse than plain adaptive even though they are much closer now - - the next tuning priority should now be: - - `A5` cepstral lifter retune with richer formant fixtures - - `A6` residual carry rebalance - - then decide whether one more adaptive boundary cleanup pass is justified before more engine-v2 work - -- `pitchOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_212441_adaptive_boundary_tune_r5_compromise` - - result: - - note mel `6.533` - - env `1.039` - - entry mel `7.462` - - exit mel `7.044` - - onset artifact `2.261` - - boundary timing error `8.396 ms` - - formant body harmonic drift `0.373` - - `spectralEnvelopeCorrectionUsed=true` -- `pitchTestOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_212441_adaptive_boundary_tune_r5_compromise` - - result: - - note mel `2.935` - - env `0.487` - - entry mel `1.833` - - exit mel `3.665` - - onset artifact `1.748` - - boundary timing error `2.250 ms` - - formant body harmonic drift `0.040` - - `spectralEnvelopeCorrectionUsed=true` -- Verdict on the current best adaptive-carrier correction pass: - - `r5` is the best adaptive correction profile so far - - it is still not keepable: - - `pitchOrg +4` note-start artifact is still materially worse than the plain adaptive baseline - - `pitchTestOrg +4` exit quality is improved but still much worse than baseline - - the next tuning priority should now be: - - `A4` entry timing compensation - - `A5` cepstral lifter retune - - then a narrower `A3` crossfade-law cleanup if the start remains rough in listening - -- `pitchOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_211448_adaptive_boundary_tune_r3` - - result: - - note mel `6.537` - - env `1.040` - - entry mel `7.435` - - exit mel `7.028` - - onset artifact `2.383` - - boundary timing error `8.396 ms` - - formant body harmonic drift `0.373` - - `spectralEnvelopeCorrectionUsed=true` -- `pitchTestOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_211448_adaptive_boundary_tune_r3` - - result: - - note mel `2.960` - - env `0.493` - - entry mel `1.876` - - exit mel `4.067` - - onset artifact `1.540` - - boundary timing error `2.250 ms` - - formant body harmonic drift `0.039` - - `spectralEnvelopeCorrectionUsed=true` -- Verdict on the second adaptive-carrier correction pass: - - this pass is better than `r2`, especially on `pitchTestOrg +4` - - it is still not keepable: - - `pitchOrg +4` onset artifact remains materially worse than the pre-correction adaptive baseline - - `pitchTestOrg +4` exit quality and boundary timing are still much worse than baseline even after the improvement - - the next tuning priority remains: - - `A3` transient handoff crossfade sweep - - `A4` entry timing compensation - - `A5` cepstral lifter retune - -- `pitchOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_210838_adaptive_boundary_tune_r2` - - result: - - note mel `6.548` - - env `1.044` - - entry mel `7.383` - - exit mel `7.007` - - onset artifact `3.259` - - boundary timing error `8.417 ms` - - formant body harmonic drift `0.372` - - `spectralEnvelopeCorrectionUsed=true` -- `pitchTestOrg +4` - - branch: `pitch_only_adaptive_selector` - - run: `20260416_210838_adaptive_boundary_tune_r2` - - result: - - note mel `3.029` - - env `0.511` - - entry mel `1.993` - - exit mel `5.269` - - onset artifact `0.824` - - boundary timing error `5.458 ms` - - formant body harmonic drift `0.038` - - `spectralEnvelopeCorrectionUsed=true` -- Verdict on the first adaptive-carrier correction pass: - - this was a real engaged run, not a stale-binary repeat - - it is still not keepable: - - `pitchOrg +4` improved note/body similarity slightly, but onset artifact got much worse - - `pitchTestOrg +4` improved onset/formant proxy, but exit quality and boundary timing got much worse - - the current tuning priority should move to: - - `A3` transient handoff crossfade sweep - - `A4` entry timing compensation - - `A5` cepstral lifter retune - -- `pitchOrg +4` - - branch: `pitch_only_engine_v2_program` - - run: `20260416_152736_pitchOrg_plus4_note_hq_engine_v2_tune_r7` - - result: - - note mel `7.314` - - env `1.347` - - entry mel `7.396` - - exit mel `7.027` - - onset artifact `1.388` - - boundary timing error `0.792 ms` - - formant body harmonic drift `0.415` -- `pitchTestOrg +4` - - branch: `pitch_only_engine_v2_program` - - run: `20260416_152736_pitchTestOrg_plus4_note_hq_engine_v2_tune_r7` - - result: - - note mel `3.540` - - env `0.521` - - entry mel `7.513` - - exit mel `1.632` - - onset artifact `4.013` - - onset delay `1.479 ms` - - formant body harmonic drift `0.069` -- Comparison baseline: - - adaptive still wins the hard case clearly: - - `pitchOrg +4`: note mel `7.085`, entry mel `7.078`, onset artifact `1.803` - - `pitchTestOrg +4`: note mel `2.810`, entry mel `1.530`, onset artifact `0.705` - -## Validation Rules -- Keep comparing on: - - `pitchOrg.wav -> pitchOrg+4s.wav` - - `pitchTestOrg.wav -> pitchTestOrg+4s.wav` -- Run guard cases only after a real `+4` win -- Track: - - note mel - - note env RMSE - - note/body/core cents - - entry and exit mel/env - - onset artifact - - harmonic-envelope drift - - render latency for note-local HQ jobs - -## Cleanup Rule -- Mark new paths as `Active Test` or `User Pending` -- Do not remove experimental code until: - - benchmark failure is recorded - - and the user has listened to it or explicitly approved cleanup diff --git a/docs/pitch_editor_research_summary.md b/docs/pitch_editor_research_summary.md deleted file mode 100644 index 66aef33..0000000 --- a/docs/pitch_editor_research_summary.md +++ /dev/null @@ -1,4523 +0,0 @@ -# Pitch Editor Research Decision Log - -Canonical status board: [pitch_recovery_master_map.md](c:/Users/srvds/Documents/Codes/Studio13-v3/docs/pitch_recovery_master_map.md) - -Decision rule from 2026-04-14 onward: -- keep lifecycle state, queue order, and harvestable traits in the master map -- keep this file as the chronological experiment ledger with exact runs, metrics, and keep/reject decisions - -### 2026-04-28: Entry Contour-Handoff Correction - -Purpose: -- test the hypothesis that the remaining start artifact is caused by a pitch-trajectory discontinuity rather than only an audio splice. -- keep render context before the note without mutating the previous word or delaying hard step edits. - -Implementation: -- added note-HQ entry pitch-handoff diagnostics through C++, the frontend bridge, and regression summaries. -- changed the native pitch-ratio curve so explicit continuous/internal transitions can use a minimum-jerk pitch handoff. -- kept hard/unknown entries on the legacy-compatible rule: pre-note render context is allowed, but the ratio reaches the target by `note.startTime` and audible dry/wet ownership stays with the entry bridge. -- added F0 slope/acceleration reporting to the reference harness; the hard gate is applied only when an actual pitch handoff is active. - -Decision: -- keep the diagnostic and explicit-continuous handoff support. -- do not use a body-delayed pitch ramp for hard/unknown entries. Trial runs with a 20-40 ms hard-entry pitch ramp regressed `pitchOrg +4` onset artifact to `1.615` and then entry lag to `19-25 ms`; the correct hard-entry behavior is render pre-roll plus dry-protected commit, not delayed pitch onset. - -Validation: -- `D:\test projects\os tests\runs\20260428_115125_entry_contour_handoff_plus4_final3`: passed, onset artifact `1.501`, exit-next `2.156`, pre-bridge residual `-172.205 dB`. -- `D:\test projects\os tests\runs\20260428_115314_entry_contour_handoff_minus4_final`: passed, onset artifact `1.187`, exit-next `0.207`, downshift harmonic drift `0.339`, spectral envelope correction enabled. -- `D:\test projects\os tests\runs\20260428_115719_entry_contour_handoff_two_adjacent_plus4_r2`: passed, one edit island for two edited notes, with internal pitch handoff diagnostics and no doubled dry/wet ownership. - -### 2026-04-28: Emergency Word-Grouping Repair - -Purpose: -- fix the regression where broad `wordGroupId` ownership caused one clicked note to move unrelated notes. -- prevent phrase-first segmentation from collapsing separate words into oversized editable regions. - -Implementation: -- restored normal UI ownership: click/drag/edit operations affect only explicitly selected notes. -- removed the word-group hull overlay and "Word (n)" inspector behavior. -- kept `wordGroupId` as assistive metadata for diagnostics/render grouping, not as automatic selection or drag ownership. -- restored conservative analyzer merging: short `40 ms` merge gap plus pitch-distance guard, instead of merging every short gap bridged by phrase context. -- allowed strong acoustic `hard_word_like` candidates to split default analyzer regions, while pitch hysteresis/vibrato candidates remain non-destructive diagnostics. -- harness metrics now separate hard acoustic splits from destructive pitch-corner/pitch-jump failures and report expected-region overhang to catch collapsed words. - -Decision: -- keep this repair immediately; product default must be "the note I clicked is the note I move." -- whole-word movement should require explicit multi-selection or a future dedicated group-edit mode, not implicit analyzer grouping. -- verification: - - analyzer: `D:\test projects\os tests\runs\20260428_110455_emergency_word_group_repair_analysis_pitchOrg`, `noteCount=5`, `wordGroupCount=5`, destructive corner/pitch-jump splits `0/0`, hard acoustic splits `2`, max expected overhang `0.459`. - - UI ownership: `pitchEditorSingleNoteOwnership.test.ts` passed; shared `wordGroupId` no longer expands selection, drag updates, or selected pitch moves. - - audio: `pitchOrg +4` and `pitchOrg -4` note-HQ runs passed; `-4` harmonic drift measured `0.343`, onset artifacts measured `1.48` / `1.598`, exit-next artifacts `2.31` / `0.091`. - - adjacent selected notes: `D:\test projects\os tests\runs\20260428_110807_emergency_repair_two_adjacent_plus4` reported one note-HQ edit island for two selected edits, confirming the double-voice ownership fix remains active. - -### 2026-04-28: Phrase-First Vibrato-Safe Word Detection - -Purpose: -- fix continued over-fragmentation where one sung word in a single breath is split by vibrato, bend, or melisma movement. -- make word/phrase regions the primary editable units while preserving pitch contour diagnostics. - -Implementation: -- removed the destructive running-average pitch-jump split from default segmentation. -- short voiced detector dropouts are bridged up to `80 ms`; hard automatic cuts require long unvoiced gaps or sustained energy-break evidence. -- sustained pitch deviations are exported as `pitch_hysteresis_*` boundary candidates, never destructive splits. -- vibrato-like periodic reversals are marked as `internal_vibrato` diagnostics and remain inside the same editable word/phrase. -- close fragments can merge across short non-hard gaps regardless of pitch distance, because pitch difference alone is not a word boundary. -- superseded by the emergency repair: the canvas no longer selects/highlights `wordGroupId` as the primary object, and dragging one internal fragment no longer moves the whole group. - -Decision: -- keep this as a product-model/analyzer correction, not a renderer experiment. -- corrected product rule: continuous breath, vibrato, bend, and melisma may stay related by metadata, but visible editing remains note-first unless the user explicitly selects multiple notes. -- destructive pitch-corner and pitch-jump splitting stays research-only/diagnostic by default. - -Validation: -- native build and frontend build passed after the change. -- analyzer run `D:\test projects\os tests\runs\20260428_040921_phrase_first_word_detection_pitchOrg` passed with `noteCount=3`, `wordGroupCount=3`, `destructivePitchJumpSplitCount=0`, `destructiveCornerSplitCount=0`, edited-word overlap `1.000`, and max fragments `1`. -- primary note-HQ runs: - - `D:\test projects\os tests\runs\20260428_040951_phrase_first_pitchOrg_plus4` - - `D:\test projects\os tests\runs\20260428_041117_phrase_first_pitchOrg_minus4` -- adjacent-fragment diagnostic `D:\test projects\os tests\runs\20260428_041244_phrase_first_two_adjacent_plus4` confirmed one raw note-HQ edit island for two supplied fragments (`noteHqEditIslandCount=1`, `noteHqEditedNoteCount=2`). - -### 2026-04-28: Word Grouping And Edit-Island Correction - -Purpose: -- fix the regression where a single sung word is broken into several editable pieces. -- fix adjacent selected notes sounding doubled by treating them as one render island. - -Implementation: -- pitch-corner detections are now `boundaryCandidates` by default, not automatic note splits. -- destructive corner splitting is available only with `OPENSTUDIO_ANALYZER_APPLY_CORNER_SPLITS=1`. -- analyzer notes now carry `wordGroupId`; close voiced fragments without a hard acoustic boundary are grouped as one editable word/phrase. -- superseded by the emergency repair: pitch move and scale correction affect explicitly selected notes only. -- note-HQ request building coalesces adjacent edited notes into edit islands; only island entry/exit get bridge ownership, while internal boundaries stay on the continuous pitch curve. -- backend commit ranges merge adjacent edited notes into island ownership and no longer average diagnostic pitch ratios with `sqrt(previous * current)`. - -Decision: -- keep this as a product-model/signal-chain correction, not a new renderer experiment. -- product rule: word-like editing is the default; internal micro-note editing requires explicit manual split. -- corner detection remains useful as an analyzer hint, but not as a default edit seam. - -Validation: -- native build and frontend build passed after the change. -- analyzer run `D:\test projects\os tests\runs\20260428_022002_word_group_analysis_pitchOrg_r2` passed with `destructiveCornerSplitCount=0` and min expected word-group overlap `0.928`. -- primary note-HQ runs: - - `D:\test projects\os tests\runs\20260428_022028_word_group_island_plus4` - - `D:\test projects\os tests\runs\20260428_022028_word_group_island_minus4` -- adjacent-fragment diagnostic `D:\test projects\os tests\runs\20260428_023520_word_group_two_adjacent_plus4_r4` confirmed one raw note-HQ edit island for two edited fragments (`noteHqEditIslandCount=1`, `noteHqEditedNoteCount=2`). - -### 2026-04-27: Segmentation-Corner And Voiced-Core Timbre Correction - -Purpose: -- address the remaining stutter as a possible wrong-boundary problem, not only a compositor problem. -- reduce downshift formant drift with an envelope transfer tied to the original voiced vowel core. - -Implementation: -- note segmentation now detects conservative pitch-curve corners from the smoothed MIDI contour. -- a corner split requires supporting vocal evidence: energy dip, confidence dip, nearby unvoiced/noise, or strong pitch prominence. -- vibrato-like periodic reversals are suppressed so normal sustained vibrato does not become many small notes. -- analyzed notes now expose `entryBoundaryKind`, `exitBoundaryKind`, reason, and score. -- note-HQ uses those boundary kinds when choosing bridge ownership: hard word-like boundaries get shorter audible bridges, while soft legato boundaries may use wider phrase smoothing. -- downward pitch-only note-HQ now applies voiced-core spectral envelope transfer on edited note bodies after the native directional renderer. - -Decision: -- keep this as a signal-chain and segmentation correction, not a new renderer-family experiment. -- product rule: previous/next word bodies stay dry across hard boundaries; continuous sustain/legato may share transition smoothing, but neighboring note bodies are not fully retuned. -- acceptance remains the primary `pitchOrg +4/-4` note-HQ gates plus the richer formant/transient/boundary suites. - -Measured runs: -- `pitchOrg +4`: `D:\test projects\os tests\runs\20260428_013135_seg_corner_timbre_plus4` - - body/core pitch error `0.00 / 0.00 cents` - - entry lag `-0.125 ms`, onset artifact `1.479`, onset derivative `1.087` - - protected pre-bridge residual `-172.823 dB` - - exit-next artifact `2.307` - - harmonic drift `0.379` -- first `pitchOrg -4` aggressive envelope-transfer attempt: `D:\test projects\os tests\runs\20260428_013344_seg_corner_timbre_minus4` - - rejected because harmonic drift regressed to `0.615 > 0.360`. - - lesson: envelope transfer must be a bounded support-weighted correction, not a replacement of the native downshift body. -- final `pitchOrg -4`: `D:\test projects\os tests\runs\20260428_013650_seg_corner_timbre_minus4_mix005` - - body/core pitch error `0.00 / +11.90 cents` - - `spectralEnvelopeCorrectionUsed=true` - - entry lag `+0.771 ms`, onset artifact `1.598`, onset derivative `1.598` - - protected pre-bridge residual `-240.000 dB` - - exit-next artifact `0.091` - - harmonic drift `0.343`, low/mid/high deltas `-1.083 / -2.067 / -0.053 dB` -- analyzer diagnostic run: `D:\test projects\os tests\runs\20260428_013900_seg_corner_boundary_analysis` - - detected `11` notes over the 4 s `pitchOrg` clip, with `2` corner-boundary notes reported. - - boundary kind counts were `hard_word_like=22`, confirming diagnostics are serialized; further product tuning should use manual vocal-boundary references before making corner splits more aggressive. - -### 2026-04-27: Entry Bridge Fix For Final Note-HQ Apply - -Purpose: -- fix the remaining edited-note entry stutter without moving the artifact back into the previous word. -- keep phrase/effective render context, but make audible entry ownership bridge-aware rather than forcing either a hard dry edge or a broad wet left shoulder. - -Implementation: -- final note-HQ compositing now chooses a direction-aware entry bridge. -- upward edits use a tight in-body bridge because the pre-note bridge regressed the upward onset metrics. -- downward edits may start audible bridge ownership up to `24 ms` before `note.startTime`; on the canonical case the bridge starts at `0.876009s`, then lands back on body timing by the end of the entry window. -- downshift bridge applies a local wet-read delay (`-21.995 ms` on `pitchOrg -4`), a bounded envelope correction (`+1.7 dB`), and a short dry transient preservation window (`10 ms`). -- diagnostics now include `noteHqEntryBridgeStartSec`, `noteHqEntryBridgeEndSec`, `noteHqEntryBridgeWetLagMs`, `noteHqEntryBridgeEnvelopeGainDb`, `noteHqEntryBridgeUsed`, and `noteHqEntryTransientDryPreservedMs`. -- the harness now protects only the pre-bridge tail, not the whole pre-body region, and writes entry-bridge audition WAVs. - -Measured runs: -- `pitchOrg +4`: `tmp_pitch_runs/20260428_004839_entry_bridge_v15_plus4` - - body/core pitch error `0.00 / 0.00 cents` - - entry bridge `0.900000s -> 0.916009s`, lag `0.0 ms`, gain `0.0 dB` - - entry lag `-0.125 ms` - - onset artifact `1.479`, onset derivative `1.087` - - protected pre-bridge residual `-172.823 dB` - - exit-next artifact `2.307` - - harmonic drift `0.379` -- `pitchOrg -4`: `tmp_pitch_runs/20260428_004708_entry_bridge_v15_minus4` - - body/core pitch error `0.00 / +11.90 cents` - - entry bridge `0.876009s -> 0.980000s`, lag `-21.995 ms`, gain `+1.7 dB` - - entry lag `+0.771 ms` - - onset artifact `1.598`, onset derivative `1.598` - - protected pre-bridge residual `-240.000 dB` - - exit-next artifact `0.091` - - harmonic drift `0.344` -- export parity: - - `tmp_pitch_runs/20260428_005155_entry_bridge_v15_plus4_export` - - `tmp_pitch_runs/20260428_005458_entry_bridge_v15_minus4_export` - - source-vs-export dry-tail residual is still informational because the mixer/export path is full-file, but preview/export body pitch and note-HQ product parity passed. - -Decision: -- keep this as a signal-chain/compositor correctness fix, not a reopened renderer-family experiment. -- product rule: final apply may use a tiny bounded pre-note bridge for best sound, but anything before `noteHqEntryBridgeStartSec` must remain original/dry. -- expected perceived match after this pass: about `91-94%` for `+4` and `89-92%` for `-4` versus the provided samples on this fixture. - -### 2026-04-27: Final Pre-Body Dry Ownership Fix For Note-HQ Apply - -Purpose: -- fix the remaining previous-word stutter without regressing the edited-note exit/next-note handoff. -- separate renderer context from audible commit ownership. - -Root cause: -- the renderer needs left-shoulder context for phase/envelope history, but committing that left shoulder as wet audio moved the stitching artifact backward into the previous word. -- the existing `preCommitArtifactScore` could miss this because it looked at a narrow boundary point rather than auditing the whole previous-word tail. - -Implementation: -- note-HQ still renders with phrase/effective context. -- final dry-protected compositing now starts audible wet ownership at `note.startTime`, not `effectiveStartTime`. -- `[effectiveStartTime, note.startTime)` remains original/dry. -- entry blends dry-to-wet inside the edited note body over `12 ms`. -- exit keeps the previous fix: wet-to-dry starts at `note.endTime - 12 ms` and releases through `effectiveEndTime`. -- diagnostics now include context range, audible commit range, pre-body dry-protected samples, entry fade ms, and exit lead-in ms. -- the harness now has a full pre-body ownership audit: - - `preBodyTailOriginalResidualDb` over `[noteStart - 80 ms, noteStart)`. - - `candidateActiveDifferenceStartSec`. - - `preBodyTailArtifactScore`. - - audition WAVs: `orig_pre_body_tail.wav`, `cand_pre_body_tail.wav`, `diff_pre_body_tail.wav`. -- the ownership audit uses `noteHqAudibleCommitStartSec`, not analysis-only body/core windows. - -Measured runs: -- `pitchOrg +4`: `tmp_pitch_runs/20260427_213815_pre_body_dry_v3_plus4` - - body/core pitch error `0.00 / 0.00 cents` - - pre-body residual `-170.878 dB` - - active difference start `0.900063s` - - onset artifact `1.706` - - exit-next artifact `2.307` - - harmonic drift `0.379` -- `pitchOrg -4`: `tmp_pitch_runs/20260427_213940_pre_body_dry_v3_minus4` - - body/core pitch error `0.00 / +11.90 cents` - - pre-body residual `-169.515 dB` - - active difference start `0.900063s` - - onset artifact `2.721` - - exit-next artifact `0.091` - - harmonic drift `0.352` -- export parity: - - `tmp_pitch_runs/20260427_214538_pre_body_dry_v4_plus4_export` - - `tmp_pitch_runs/20260427_214814_pre_body_dry_v4_minus4_export` - - note: source-vs-export dry residual is informational only because the mixer/export path changes the full file from time zero; export parity is judged against the note-HQ product. -- richer formant suite: - - `tmp_pitch_runs/pre_body_dry_v2_formant_richer/20260427_212751_pre_body_dry_v2_formant_richer` -- richer transient suite: - - `tmp_pitch_runs/pre_body_dry_v4_transient_richer/20260427_215055_pre_body_dry_v4_transient_richer` -- boundary suite: - - `tmp_pitch_runs/pre_body_dry_v2_boundary/20260427_212152_pre_body_dry_v2_boundary` - - primary real `pitchOrg` cases stay under the exit-next gate; the synthetic shortened-end stress case still warns with a high exit-next artifact. - -Decision: -- keep this as a signal-chain/compositor correctness fix, not a renderer-family experiment. -- product rule: render context may extend before the edited note, but final committed audio before `note.startTime` must remain original/dry. -- expected perceived match after this pass: about `89-92%` for `+4` and `87-90%` for `-4` versus the provided samples, with the remaining gap mostly from timbre/body-envelope match rather than word-break stitching. - -### 2026-04-27: Pitch-Only Signal-Chain Fix, Note-HQ Slicing Fix, And Transition-Shoulder Commit - -Purpose: -- fix the reported pitch-note change failures as signal-chain bugs: - - timbre getting thinner/fatter when pitch moves - - stutter/word-break just before or after the edited note -- stop accepting low-confidence note-HQ fallback silently -- make the regression harness catch formant/spectrogram drift on the real candidate slice - -Root causes found: -- several `SignalsmithShifter::process(...)` pitch-only callsites passed `detectedPitchHz` as the sixth argument, which is actually `formantRatios`; that could turn F0 Hz values into huge unintended formant factors. -- `note_hq` regression slicing treated every candidate like a window-local render, even when the app wrote a full-clip/phrase result; this let formant and boundary metrics inspect the wrong slice. -- note-HQ apply request construction dropped the note's own transition shoulders when there was no immediate neighbor, so the dry patch still committed only the body and could hard-switch at word edges. - -Implementation: -- added explicit `SignalsmithShifter` pitch-only entrypoints: - - `processPitchOnlyBase(..., ratios, detectedPitchHz)` - - `processPitchOnlyCe33Base(..., ratios)` -- routed pitch-only detected-F0 renders through `process(..., ratios, {}, detectedPitchHz)`, keeping explicit formant rendering on the non-empty `formantRatios` path only. -- changed note-HQ pitch-only final render to require phrase/full-context offline HQ unless `OPENSTUDIO_PITCH_ALLOW_NOTE_HQ_NATIVE_FALLBACK=1` is set. -- expanded note-HQ commit ownership to include effective transition shoulders; for the canonical `0.900s-1.550s` body the final diagnostic runs commit `0.860s-1.610s`. -- added preview segment entry/exit crossfade to avoid hard-switching cached chunks against the source clip. -- fixed the formant proxy peak scorer to order selected broad peaks by frequency before treating them as F1/F2 proxies. - -Measured runs: -- production HQ-required check: - - `20260427_135602_after_fix_pitchOrg_plus4_hq_required_missing_runtime` - - expected failure in this checkout: bundled Rubber Band executable exits with Windows status `0xC0000135` because dependent runtime DLLs are missing. - - result is now a hard failure, not a silent native fallback. -- follow-up runtime fix: - - added `tools/rubberband/sndfile.dll` from the bundled Python `libsndfile_x64.dll`, plus the matching VC runtime DLLs. - - `rubberband.exe --version` and `rubberband-r3.exe --version` now report `4.0.0`. - - app-path result `20260427_145800_rubberband_runtime_fixed_pitchOrg_plus4` used `rubberband_hq_phrase_hq` with `phraseHqExternalUsed=true` and `pitchRenderBackendVersion=4.0.0`. - - quality status is still not promoted: `20260427_145847_rubberband_runtime_fixed_pitchOrg_plus4` failed the strict formant gate at mid-band delta `+7.866 dB > 7.000 dB` and boundary timing `38.54 ms`. -- final debug-native `pitchOrg +4`: - - run: `20260427_135220_after_fix_pitchOrg_plus4_native_override_final` - - body/core pitch error: `0.00 / 0.00 cents` - - note mel/env: `6.804 dB / 1.258` - - formant body harmonic drift: `0.378` - - low/mid/high deltas: `-2.46 / +0.53 / -3.59 dB` - - core F1/F2 proxy drift: `-46.9 / +23.4 Hz` - - boundary timing error: `8.42 ms` - - onset artifact score: `1.77` - - spectrogram assets: `D:\test projects\os tests\runs\20260427_135220_after_fix_pitchOrg_plus4_native_override_final\spectrogram` -- final debug-native `pitchOrg -4`: - - run: `20260427_135413_after_fix_pitchOrg_minus4_native_override_final` - - body/core pitch error: `0.00 / +11.90 cents` - - note mel/env: `6.557 dB / 1.086` - - formant body harmonic drift: `0.469` - - low/mid/high deltas: `-0.41 / +0.83 / +1.12 dB` - - core F1/F2 proxy drift: `+46.9 / -70.3 Hz` - - boundary timing error: `25.38 ms` - - onset artifact score: `0.94` - - spectrogram assets: `D:\test projects\os tests\runs\20260427_135413_after_fix_pitchOrg_minus4_native_override_final\spectrogram` -- richer suites: - - formant richer suite passed all `6` cases: `20260427_135901_after_fix_formant_richer_native_override` - - transient richer suite passed all `8` cases: `20260427_141431_after_fix_transient_richer_native_override` - - export/preview parity passed for `pitchOrg +4`: `20260427_143420_after_fix_export_preview_parity_plus4_native_override` -- boundary suite: - - run: `20260427_142907_after_fix_boundary_pitchOrg_plus4_native_override` - - `start_earlier` and `start_later` passed. - - synthetic `end_earlier` failed the new hard gate: boundary timing `38.938 ms > 32 ms`. - - decision: keep this as a known strict stress failure, not a pass. - -Decision: -- this is a correctness fix to the existing signal chain and harness, not a reopened renderer-family experiment. -- keep `pitch_only_adaptive_selector` as the native diagnostic fallback. -- production note-HQ needs the external HQ backend/runtime fixed before claiming final HQ parity. - -### 2026-04-17: Root-Cause Research + ML Benchmark + Engine-v3 Feasibility - -Purpose: -- stop doing blind renderer experiments -- turn the remaining two product issues into evidence-backed causes -- quickly decide whether local ML restoration or a clean-sheet `engine-v3` branch is actually credible - -New tooling: -- `tools/pitch_root_cause_research.py` -- `tools/run-pitch-root-cause-research.ps1` -- `tools/run-pitch-ml-benchmark.ps1` -- `tools/pitch_engine_v3_feasibility.py` -- `tools/run-pitch-engine-v3-feasibility.ps1` - -Primary outputs: -- root-cause: - - `20260417_012542_pitch_root_cause_research` - - summary doc: - - `D:\test projects\os tests\runs\20260417_012542_pitch_root_cause_research\pitch_root_cause_research.md` -- ML benchmark: - - `20260417_003355_pitch_ml_benchmark` -- engine-v3 feasibility: - - `20260417_003355_engine_v3_feasibility` -- repo summary: - - `docs/pitch_root_cause_research_20260417.md` - -Root-cause verdicts: -- rank 1: - - transition ownership and boundary timing drift - - evidence: - - hard-case adaptive boundary timing still peaks around `18.92 ms` - - frozen engine-v2 still collapses at hard-case entry with entry mel `7.017` -- rank 2: - - mixed transient and first-voiced-cycle content is still being handled by one renderer family - - evidence: - - hard-case transient entry/exit max stayed about `1.614 / 6.723` - - engine-v2 still failed with transient bypass enabled -- rank 3: - - current formant preservation is too weak and too local to survive hard transitions - - evidence: - - adaptive formant drift remained around `0.364` on `pitchOrg` and `0.071` on `pitchTest` - - engine-v2 kept envelope correction on but still lost the hard case - -ML benchmark verdict: -- result: - - `blocked_no_stronger_restorer` -- environment: - - runtime ready `true` - - backend `cuda` -- candidate check: - - `voicefixer`: not installed - - `demucs`: not installed - - `audio_separator`: available but not suitable for note-local restoration - - `proxy_ml_restore_v1`: explicitly excluded and already rejected -- decision: - - do not reopen local ML restoration on another proxy - - only reopen once a materially stronger restorer is available - -Engine-v3 feasibility verdict: -- decomposition probe results: - - `pitchOrg_plus4`: score `0.511`, verdict `stop` - - `pitchTest_plus4`: score `0.505`, verdict `stop` -- decision: - - do not open a long `engine-v3` implementation branch from this probe - - only revisit `engine-v3` after defining a materially stronger decomposition and transition-pair ownership model - -Meaning for the program: -- current best renderer remains `pitch_only_adaptive_selector` -- engine-v2 remains frozen as comparison evidence only -- the unsolved product issues are now understood as architectural rather than just tuning leftovers -- next bounded action should be: - - stronger external/licensed or materially stronger ML benchmark, or - - a better pre-defined decomposition/transition design before any future `engine-v3` - -### 2026-04-16: Richer Fixture Close-Out Progress, `H3/H4` Closed For Current Canonical Corpus - -Purpose: -- stop leaving the remaining iteration budget tied to smoke-only fixture coverage -- close out richer transient and formant suites on the current canonical clip families -- tighten the scrub-suite truth so the only remaining mandatory gap is the real multi-note selection-change case - -What changed: -- added richer manifests: - - `tests/fixtures/pitch-regression/suites/transient_richer_suite.json` - - `tests/fixtures/pitch-regression/suites/formant_richer_suite.json` -- extended the scrub-suite plumbing to request a selection-change scenario when available -- ran richer scrub suites on both canonical clip families: - - `20260416_231821_pitchOrg_scrub_suite_richer_r1` - - `20260416_234516_pitchTest_scrub_suite_richer_r1` -- ran richer regression suites: - - transient: `20260416_231844_transient_suite_richer_r1` - - formant: `20260416_233426_formant_suite_richer_r1` - -Scrub-suite truth: -- first drag, repeated drag, and after-transport-cycle are all audible on both clip families -- representative scrub figures: - - `pitchOrg` - - start / stop latency about `26-28 ms` - - repeat stability `0.4699` - - last peak `0.1123` - - `pitchTestOrg` - - start / stop latency about `26-28 ms` - - repeat stability `0.7770` - - last peak `0.1313` -- verdict: - - `H1` is still only partial - - the suite now covers the major stopped-transport scrub scenarios - - but true selection-change is still unproven because the current scrub fixtures resolve to one-note jobs, so the added scenario does not yet exercise a real second-note handoff - -Transient richer-suite highlights: -- `pitchOrg +4` richer entry/exit runs: - - note mel about `7.095` - - onset artifact about `1.212` - - formant drift about `0.367` -- `pitchTestOrg +4` richer entry/exit runs: - - note mel about `4.915` - - entry artifact about `1.58-1.61` - - exit artifact about `6.36-6.72` - - boundary timing error about `11.60-18.92 ms` -- `pitchTestOrg -4` richer entry/exit runs: - - note mel about `5.743` - - exit artifact about `7.90-8.41` - - boundary timing error about `16.54-35.40 ms` -- verdict: - - `H3` is now complete for the current canonical local fixture corpus - - the suite confirms the remaining weakness is still boundary behavior, especially hard-case exits and downward guards - -Formant richer-suite highlights: -- `pitchOrg +4` body / transition formant runs: - - formant drift about `0.360-0.367` - - entry / exit artifact still about `6.59-7.05` -- `pitchTestOrg +4` body / transition formant runs: - - formant drift about `0.063-0.080` - - entry artifact about `1.58-1.77` - - exit artifact about `4.22-6.72` -- `pitchTestOrg -4` body formant run: - - formant drift `0.0569` - - note/body cents still off on the downward guard -- verdict: - - `H4` is now complete for the current canonical local fixture corpus - - the suite confirms formant drift is still materially worse on the easier `pitchOrg` family than on `pitchTestOrg`, while boundary artifacts still dominate the listening problem overall - -Current plan truth: -- remaining mandatory iterations: `1` -- remaining conditional iterations: `+2` -- the one mandatory item still open is: - - true multi-note `H1` scrub selection-change coverage - -### 2026-04-16: `H1` Scrub Selection-Change Close-Out - -Purpose: -- close the last remaining mandatory harness gap instead of leaving scrub selection-change as a half-wired scenario -- prove the app-path scrub job can exercise a real second-note handoff, not just repeat `first_drag` - -What changed: -- fixed scrub note-array flattening in `tools/run-ui-pitch-scrub-regression.ps1` -- added multi-note scrub fixtures: - - `tests/fixtures/pitch-regression/example_pitchOrg_scrub_multinote.json` - - `tests/fixtures/pitch-regression/example_pitchTest_scrub_multinote.json` -- added scrub debug/result fields in the app-path regression flow so the selection-change scenario could be verified directly - -Verification runs: -- direct debug run: - - `20260416_235624_pitchTest_scrub_selection_debug_r1` - - result: - - `scrubPreviewSelectionChangeAudible=true` - - scenario count `2` - - scenario names: - - `first_drag` - - `selection_change` -- full multi-note scrub suite: - - `20260416_235640_pitchTest_scrub_suite_multinote_r3` - - result: - - `first_drag`: audible `true` - - `repeated_drag`: audible `true` - - `after_transport_cycle`: audible `true` - - `selection_change`: audible `true` - - selection-change case reports: - - `scrubPreviewSelectionChangeAudible=true` - - start latency `27.2 ms` - - stop latency `27.6 ms` - - repeat stability `0.4174` - - last peak `0.0991` - -Verdict: -- `H1` is now complete for the current canonical local fixture corpus -- there are no mandatory harness-closeout iterations left -- the remaining work from here is no longer “unfinished implementation”; it is product-quality improvement work, plus the optional conditional phase-locking branch if we decide to open it - -Current plan truth: -- remaining mandatory iterations: `0` -- remaining conditional iterations: `+2` - -### 2026-04-16: Scrub Preview Natural-Segment Fix Landed, Boundary/Formant Work Still Pending - -Purpose: -- fix the user-facing scrub-preview failure instead of treating the older infrastructure-only pass as complete -- make drag preview audible on first drag and stop the tiny-loop tearing behavior -- keep the boundary/formant work explicitly marked as still incomplete - -What changed: -- scrub preview now serializes note/frame payloads across the native bridge for a more reliable native parse -- scrub extraction now prefers the clip's active audio source instead of the preserved original-file path -- scrub extraction now falls back to deriving note bounds from analyzed frames if note metadata arrives incomplete -- scrub preview now uses: - - a longer natural note-local voiced segment - - gain normalization - - repeat-stability telemetry - - preview armed / first-callback / first-drag-audible status flags - -Fresh scrub regression: -- run: `20260416_200227_pitchOrg_scrub_preview_r8` -- result: - - `scrubPreviewAudible=true` - - `scrubPreviewFirstDragAudible=true` - - start latency `26.9 ms` - - stop latency `26.8 ms` - - base pitch `365.17 Hz` - - loop duration `240.0 ms` - - last peak `0.1123` - - repeat stability `0.4699` - -Verdict: -- the scrub path is now materially closer to the intended product behavior and no longer falls back to the near-silent `40 ms / 55 Hz` path in the harness -- this closes the core `S1/S2` scrub implementation work and most of `S3` -- boundary tuning, formant tuning, the full boundary/transient/formant suites, and the full adaptive/engine-v2 tuning program are still not complete - -### 2026-04-16: Dedicated RAM Scrub Voice Implemented, First Full Engine-V2 Audio Pass Active - -Purpose: -- finish the remaining implementation work that the 2-tier pitch-editor program still needed -- replace the old “interactive preview piggyback” assumption with a true RAM scrub monitor path -- move `engine-v2` from diagnostics/scaffold territory into a real benchmarkable audio renderer - -What was implemented: -- dedicated scrub-preview pipeline - - native bridge entrypoints: - - `startPitchScrubPreview` - - `updatePitchScrubPreview` - - `stopPitchScrubPreview` - - scrub loops are extracted from the stable interior of the selected note, not the onset - - loops live entirely in RAM and are mixed directly by `PlaybackEngine` - - stereo clips are supported by deriving loop bounds from mono analysis and extracting matching multichannel audio -- first real full `engine-v2` audio pass: - - Signalsmith-based voiced-core render in the transition window - - cepstral envelope restoration on voiced-support frames - - spectral-flatness transient/unvoiced bypass mask - - residual carry reinjection from shared own-engine analysis - - transition compositor layered on top of the frozen adaptive selector output - -Primary truth-case results: -- `pitchOrg +4` - - adaptive benchmark: - - run: `20260416_115127_pitchOrg_plus4_note_hq_adaptive_r2` - - note mel `7.085` - - env `1.376` - - entry `7.078` - - exit `7.027` - - onset artifact `1.80` - - engine-v2 full audio: - - run: `20260416_124252_pitchOrg_plus4_note_hq_engine_v2_program_impl_r2` - - note mel `9.653` - - env `1.782` - - entry `10.430` - - exit `11.219` - - onset artifact `3.23` - - note/body/core cents `-18.72 / -18.72 / -37.23` - - `spectralEnvelopeCorrectionUsed=true` - - `engineV2Used=true` - - verdict: - - the new renderer is real, but it is still not keepable; it is pulling the easy truth case too far away from the adaptive benchmark -- `pitchTestOrg +4` - - adaptive benchmark: - - run: `20260416_115302_pitchTestOrg_plus4_note_hq_adaptive_r2` - - note mel `2.810` - - env `0.470` - - entry `1.530` - - exit `1.632` - - onset artifact `0.70` - - engine-v2 full audio: - - run: `20260416_124622_pitchTestOrg_plus4_note_hq_engine_v2_program_impl_r3` - - note mel `11.303` - - env `1.425` - - entry `8.389` - - exit `10.274` - - onset artifact `0.85` - - note/body/core cents `-17.94 / -17.94 / -36.45` - - `spectralEnvelopeCorrectionUsed=true` - - `engineV2Used=true` - - verdict: - - the hard truth case is still much worse than the frozen adaptive benchmark - -Decision: -- keep all code in place -- mark the family `Active Test / User Pending` -- do not remove the new scrub voice or engine-v2 code -- next tuning step must narrow engine-v2 engagement around the transition nucleus instead of letting it own such a wide region - -### 2026-04-16: Engine-V2 Transition-Nucleus Tightening - -Purpose: -- reduce the catastrophic full-note drift from the first engine-v2 audio pass -- keep the adaptive selector as the dominant carrier -- let engine-v2 touch only the onset-transition nucleus for upward note changes - -What changed: -- transition ownership moved from the wider island/body window to a much tighter note-entry window -- engine-v2 now adds deltas on top of the adaptive baseline instead of composing a broader replacement mix -- voiced-core wet level and residual carry were reduced -- transient preservation was made more dominant at the entry edge - -Primary truth-case results: -- `pitchOrg +4` - - run: `20260416_125615_pitchOrg_plus4_note_hq_engine_v2_program_impl_r4` - - result: - - note mel `7.323` - - env `1.419` - - entry mel `7.918` - - exit mel `7.027` - - onset artifact `1.66` - - note/body/core cents `0.00 / 0.00 / 0.00` - - verdict: - - much safer than the first full engine-v2 pass and exact on whole-note/body pitch, but still worse than adaptive overall because entry quality drifted and note/env did not improve enough -- `pitchTestOrg +4` - - run: `20260416_125615_pitchTestOrg_plus4_note_hq_engine_v2_program_impl_r4` - - result: - - note mel `3.250` - - env `0.536` - - entry mel `4.277` - - exit mel `1.632` - - onset artifact `1.95` - - note/body/core cents `0.00 / 0.00 / -18.32` - - verdict: - - far better than the original catastrophic engine-v2 attempt, but still clearly worse than the adaptive selector on the hard truth case - -Follow-up tuning: -- `r5` tightened wet ownership even further: - - `20260416_125930_pitchOrg_plus4_note_hq_engine_v2_program_impl_r5` - - `20260416_125930_pitchTestOrg_plus4_note_hq_engine_v2_program_impl_r5` -- result: - - metrics stayed effectively the same as `r4` - - this indicates the current engine-v2 topology is now failing in a stable way rather than exploding numerically - -Additional tightening: -- `r6` protected the first voiced cycles longer and pushed engine-v2 takeover deeper into the entry: - - `20260416_131617_pitchOrg_plus4_note_hq_engine_v2_program_impl_r6` - - `20260416_131617_pitchTestOrg_plus4_note_hq_engine_v2_program_impl_r6` -- result: - - `pitchOrg +4` - - note mel `7.313` - - env `1.365` - - entry mel `8.121` - - onset artifact `1.48` - - exact note/body/core cents `0 / 0 / 0` - - `pitchTestOrg +4` - - note mel `3.429` - - env `0.508` - - entry mel `5.278` - - onset artifact `3.81` - - note/body cents exact but core still `-18.32` -- verdict: - - the easy truth case became somewhat safer and preserved pitch/body exactly - - the hard truth case still loses badly on note-entry quality, so this compositor family is now plateauing as a waveform-correction overlay - -Current interpretation: -- implementation is complete enough to audition: - - dedicated RAM scrub preview exists - - engine-v2 voiced-core + cepstral envelope + transient bypass + residual carry exists -- the main audible issues are still not solved -- the remaining problem is now narrower: - - engine-v2 is no longer catastrophically unstable - - but its current transition compositor still makes the hard note entry worse than the adaptive selector - - the next meaningful move is likely a different engine-v2 role, such as using engine-v2 primarily as a formant/timbre correction layer on top of the adaptive carrier instead of as a waveform-correction overlay - -### 2026-04-16: `FAM-ENGINE-V2` `V2-1` Scaffold Started - -Purpose: -- begin the engine-v2 fallback program without risking the current best editor -- make the new branch fail-closed to `pitch_only_adaptive_selector` -- prove we can extract transition-native support signals before changing audible output - -What was implemented: -- new safe benchmark branch: - - `pitch_only_engine_v2_program` -- new engine-v2 diagnostics flowing through the native/app regression path: - - `engineV2Used` - - `engineV2FallbackUsed` - - `engineV2TransitionCount` - - `engineV2TransitionStartSec` - - `engineV2TransitionEndSec` - - `engineV2HarmonicSupportPeak` - - `engineV2ResidualSupportPeak` - - `engineV2EnvelopeSupportPeak` -- current `V2-1` behavior: - - render exactly the frozen adaptive-selector output - - compute transition/harmonic/residual/envelope scaffold diagnostics from `OwnPitchEngine` shared analysis - - report those diagnostics through the regression summaries - -Primary smoke runs: -- `pitchOrg +4` - - run: `20260416_104358_v2_scaffold_pitchOrg_plus4_smoke_r4` - - result: - - requested/actual branch: `pitch_only_engine_v2_program / pitch_only_engine_v2_program` - - output SHA: `F4BFBE23347CA853EEDE27E48E70F24DC5983AABE37498D02CD8F6D518B3EC72` - - `engineV2Used=true` - - transition count: `1` - - transition window: `0.090–0.830s` - - support peaks: harmonic `0.982`, residual `0.075`, envelope `2.379` - - verdict: - - safe and bit-identical to the frozen adaptive benchmark, with real scaffold engagement -- `pitchTestOrg +4` - - run: `20260416_104610_v2_scaffold_pitchTestOrg_plus4_smoke_r1` - - result: - - requested/actual branch: `pitch_only_engine_v2_program / pitch_only_engine_v2_program` - - output SHA: `1164BB74D7FC7EC87B163790DAF763E94B1B79812B7BE36F3FCD97966CFA740A` - - `engineV2Used=true` - - transition count: `1` - - transition window: `0.090–1.260s` - - support peaks: harmonic `0.982`, residual `0.075`, envelope `2.192` - - verdict: - - safe and bit-identical to the frozen adaptive benchmark on the harder truth case too - -Program state after `V2-1`: -- engine-v2 is now an active family rather than a placeholder -- the scaffold is fail-closed, measurable, and safe on both main `+4` truth clips -- this does not solve the stutter/formant problem yet; it just gives us a trustworthy base for `V2-2` - -### 2026-04-16: `FAM-ENGINE-V2` First `V2-2` Core-Blend Attempt Rejected - -Purpose: -- take one conservative audible step beyond the scaffold -- keep the engine-v2 branch on the same diagnostics/transition windows -- test whether a light own-engine voiced-core bleed-in could help without destabilizing the frozen adaptive output - -What was tried: -- a conservative stable-core blend on top of `pitch_only_engine_v2_program` -- the blend only allowed own-engine output into the analyzed voiced support region -- the rest of the branch still followed the adaptive-selector output - -Primary truth-case result: -- `pitchOrg +4` - - run: `20260416_105142_v2_2_pitchOrg_plus4_g1` - - result: - - note mel `7.085 -> 7.175` - - env `1.376 -> 1.421` - - entry mel `7.078 -> 7.321` - - exit mel `7.027 -> 7.418` - - onset artifact `1.80 -> 1.75` - - verdict: - - not keepable; the note, entry, and exit all drifted the wrong way for only a tiny onset gain -- `pitchTestOrg +4` - - run: `20260416_105142_v2_2_pitchTestOrg_plus4_g1` - - result: - - note mel `6.292 -> 8.192` - - env `1.130 -> 1.236` - - entry mel `1.530 -> 9.507` - - exit mel `1.632 -> 2.027` - - note cents `-125.96 -> -141.08` - - onset artifact `0.70 -> 1.13` - - verdict: - - clearly worse on the harder truth case - -Decision: -- rejected immediately under the stop-fast rule -- restored `pitch_only_engine_v2_program` to the safe `V2-1` scaffold after the trial - -Parity restore check after rollback: -- `pitchOrg +4` - - run: `20260416_110003_v2_scaffold_pitchOrg_plus4_parity_check_r6` - - result: - - SHA back to `F4BFBE23347CA853EEDE27E48E70F24DC5983AABE37498D02CD8F6D518B3EC72` - - still `engineV2Used=true` with transition/support diagnostics intact -- `pitchTestOrg +4` - - run: `20260416_110152_v2_scaffold_pitchTestOrg_plus4_parity_check_r2` - - result: - - SHA back to `1164BB74D7FC7EC87B163790DAF763E94B1B79812B7BE36F3FCD97966CFA740A` - - still `engineV2Used=true` with transition/support diagnostics intact - -Current engine-v2 state: -- scaffold remains active and safe -- first audible `V2-2` attempt is rejected -- the next `V2-2` candidate must be structurally different from a simple stable-core blend - -### 2026-04-15: Remaining Research Close-Out - -Purpose: -- close the remaining credible families cleanly instead of leaving half-open work items in the queue -- freeze the current best editor on measured evidence -- decide whether the next move is another local family or the engine-v2 fallback - -What was implemented: -- added an analysis-only regression harness: - - [run-ui-pitch-analysis-regression.ps1](c:/Users/srvds/Documents/Codes/Studio13-v3/tools/run-ui-pitch-analysis-regression.ps1) - - shared job-driver support in [pitchRegressionDriver.ts](c:/Users/srvds/Documents/Codes/Studio13-v3/frontend/src/utils/pitchRegressionDriver.ts) -- used that harness to close out `FAM-ANALYZER-PYIN` -- reran the four canonical truth cases on `pitch_only_adaptive_selector` to freeze it as the benchmark branch -- checked the repo for a stronger ML restorer than `ml_restore_proxy_v1` -- checked the remaining PV status against the actual local implementation inventory - -Analyzer close-out: -- `A1` direct-YIN + decoder on `pitchOrg` - - run: `20260415_210932_A1_pitchOrg_direct` - - result: - - notes detected / expected: `7 / 1` - - voiced frame ratio: `0.802` - - median voiced confidence: `0.974` - - matched expected note window: yes - - overlap ratio: `0.964` -- `A1` direct-YIN + decoder on `pitchTestOrg` - - run: `20260415_210932_A1_pitchTestOrg_direct` - - result: - - notes detected / expected: `6 / 1` - - voiced frame ratio: `0.676` - - median voiced confidence: `0.976` - - matched expected note window: yes - - overlap ratio: `0.796` -- `A2` FFT-YIN parity decision - - runs: - - `20260415_210932_A2_pitchOrg_fft` - - `20260415_210932_A2_pitchTestOrg_fft` - - result: - - `0` detected notes on both fixtures - - voiced frame ratio `0.000` on both fixtures - - verdict: - - direct-YIN + decoder is frozen as the kept analyzer path - - FFT-YIN is formally rejected-for-now and remains gated off - -Broader adaptive-selector validation: -- runs: - - `20260415_211004_closeout_pitchOrg_plus4_adaptive` - - `20260415_211004_closeout_pitchOrg_minus4_adaptive` - - `20260415_211004_closeout_pitchTestOrg_plus4_adaptive` - - `20260415_211004_closeout_pitchTestOrg_minus4_adaptive` -- result: - - `pitchOrg +4` - - note mel `7.085` - - env `1.376` - - entry `7.078` - - exit `7.027` - - onset artifact `1.80` - - `pitchOrg -4` - - note mel `6.623` - - env `1.102` - - entry `6.585` - - exit `7.475` - - onset artifact `2.80` - - `pitchTestOrg +4` - - note mel `2.810` - - env `0.470` - - entry `1.530` - - exit `1.632` - - onset artifact `0.70` - - `pitchTestOrg -4` - - note mel `3.505` - - env `1.024` - - entry `3.090` - - exit `1.835` - - onset artifact `3.64` -- verdict: - - `pitch_only_adaptive_selector` is now frozen as the benchmark branch for any future engine-v2 work - -Remaining-family close-out decision: -- `FAM-ML-RESTORATION` - - no materially stronger trained restorer exists locally beyond `ml_restore_proxy_v1` - - family is deferred behind engine-v2 -- `FAM-PVDR` - - no materially different stable implementation is ready locally beyond the rejected resample-plus-phase-lock attempt - - family stays closed unless a genuinely different design is prepared - -Program conclusion: -- remaining close-out work is complete -- the current product issues are still unresolved: - - stutter on note changes - - formant/timbre change on note changes -- the next serious step is no longer another local family patch -- the next serious step is the `FAM-ENGINE-V2` fallback program - -### 2026-04-15: `FAM-TRANSITION-HQ` Stop-Fast Verdict - -Purpose: -- try a proper note-change-specific HQ renderer instead of another full-note shell swap -- keep `pitch_only_adaptive_selector` frozen as the live working editor -- benchmark a transition-only shell/core/residual overlay on top of the adaptive output before even considering an ML finish - -What was implemented for the trial: -- temporary branch key: - - `pitch_only_transition_hq` -- temporary HQ-only transition overlay on top of `pitch_only_adaptive_selector`: - - preserve original shell content near the note-change edge - - blend in own-engine voiced core support only through the transition window - - reinject a small residual carry inside the transition core - - apply simple local envelope correction between adaptive and own-engine outputs -- reusable diagnostics were added to the regression/native result flow: - - `transitionHqUsed` - - `transitionHqFallbackUsed` - - `transitionStartSec` - - `transitionEndSec` - - `transitionTransientPeak` - - `transitionVoicedCorePeak` - - `transitionResidualPeak` - - `transitionEnvelopeCorrectionUsed` - -Truth-case results versus the current adaptive selector: -- `pitchOrg +4` - - adaptive control run: `20260415_185731_pitchOrg_plus4_adaptive_transition_cmp` - - transition HQ run: `20260415_185258_pitchOrg_plus4_transition_hq_m1` - - result: - - note mel `7.085 -> 7.395` - - env `1.376 -> 1.434` - - entry mel `7.078 -> 7.723` - - exit mel `7.027 -> 7.027` - - onset artifact `1.80 -> 1.59` - - note/body/core cents stayed exact at `0 / 0 / 0` - - `transitionHqUsed=true` - - transition peaks `0.829 / 0.840 / 0.073` - - verdict on this case: - - onset improved a little, but note, envelope, and entry all got worse -- `pitchTestOrg +4` - - adaptive control run: `20260415_185942_pitchTestOrg_plus4_adaptive_transition_cmp` - - transition HQ run: `20260415_185446_pitchTestOrg_plus4_transition_hq_m1` - - result: - - note mel `2.810 -> 4.084` - - env `0.470 -> 0.772` - - entry mel `1.530 -> 15.735` - - exit mel `1.632 -> 1.632` - - onset artifact `0.70 -> 3.73` - - core cents stayed `-18.32`, matching the underlying hard-case pitch issue - - `transitionHqUsed=true` - - transition peaks `0.574 / 0.840 / 0.073` - - verdict on this case: - - the overlay made the hard truth case dramatically worse - -Verdict: -- rejected after the first DSP iteration -- it failed the keep gate decisively: - - one case only improved the onset score while harming note/body entry quality - - the other primary case regressed heavily on note mel, envelope, entry, and onset artifact - - there was no justification to open the optional ML-finish stage - -Cleanup: -- removed the temporary `pitch_only_transition_hq` branch support from the renderer and harness after the verdict -- kept the transition diagnostics plumbing in place for future benchmark reporting if a materially different engine-v2 path needs similar measurements -- the live editor remains `pitch_only_adaptive_selector` - -### 2026-04-15: Research Synthesis Before The Next Renderer Family - -Purpose: -- stop guessing between "modern sounding" ideas and actually research the remaining families that are still credible -- separate what the repo already tried from what the literature still supports -- update the queue so the next work is evidence-backed instead of another near-duplicate shell variation - -Primary-source takeaways: -- median-filter HPSS is a real and useful separation technique, but it is a decomposition step, not a complete vocal pitch-editor renderer - - source: FitzGerald 2010 DAFx paper -- WSOLA is strong for local continuity and time-scale seams, but not a complete answer to vocal pitch/body/formant artifacts - - source: Verhelst and Roelands 1993 -- research-grade phase-vocoder work is still genuinely untried here - - the repo only tried a lightweight boundary-alignment variant - - sources: Laroche and Dolson 1999, and "Phase Vocoder Done Right" 2022 -- DDSP is the strongest engine-v2 style research direction if we accept a larger redesign - - source: DDSP 2020 -- the shallow-diffusion singing restoration paper is the closest direct match to the repo’s real product problem - - start from a usable pitch-shifted render - - then restore natural singing quality while preserving melody and timing - - source: Liu and Akama 2026 -- WORLD remains useful as a support decomposition for envelope/aperiodicity features, not as the final renderer target - - source: WORLD 2016 - -Decision from the literature pass: -- stop treating more seam-only or shell-only DSP tweaks as the highest-value next step -- keep the current best working editor (`pitch_only_adaptive_selector`) frozen as the live benchmark -- move the next queue to: - - `FAM-PVDR` - - `FAM-ML-RESTORATION` - - broader validation on `FAM-ADAPTIVE-SELECTOR` - -Repo note: -- the full research note and source list is now in [pitch_renderer_research_notes.md](c:/Users/srvds/Documents/Codes/Studio13-v3/docs/pitch_renderer_research_notes.md) - -### 2026-04-15: `FAM-ML-RESTORATION` Proxy Benchmark Verdict - -Purpose: -- execute the next research-backed direction without touching the live editor path -- keep `pitch_only_adaptive_selector` frozen as the base renderer -- test restoration-after-render as an offline benchmark, not a shipping path - -What was implemented for the trial: -- new offline benchmark script: [ml_restore_benchmark.py](c:/Users/srvds/Documents/Codes/Studio13-v3/tools/ml_restore_benchmark.py) -- regression harness integration in [run-ui-pitch-regression.ps1](c:/Users/srvds/Documents/Codes/Studio13-v3/tools/run-ui-pitch-regression.ps1) -- benchmark-only diagnostics recorded through the existing summary flow: - - `mlRestoreUsed` - - `mlRestoreModelId` - - `mlRestoreWindowSec` - - `mlRestoreBaseBranch` -- first proxy model id: - - `ml_restore_proxy_v1` -- `M1` proxy behavior: - - render with `pitch_only_adaptive_selector` - - restore only the note window offline - - use pYIN-derived F0 conditioning from the original window - - use original-window energy conditioning - - apply transient-emphasis blending near note entry - - apply harmonic spectral-envelope correction on the rendered candidate - -Truth-case results versus the current adaptive selector: -- `pitchOrg +4` - - adaptive control run: `20260415_142514_pitchOrg_plus4_adaptive_m1_cmp` - - ML restore run: `20260415_141959_pitchOrg_plus4_ml_restore_m1` - - result: - - note mel `7.085 -> 6.736` - - env `1.376 -> 1.291` - - entry mel `7.078 -> 6.716` - - exit mel `7.027 -> 6.629` - - onset artifact `1.80 -> 1.77` - - note/body/core cents stayed exact at `0 / 0 / 0` - - verdict on this case: - - promising on the easier upward truth case -- `pitchTestOrg +4` - - adaptive control run: `20260415_142514_pitchTestOrg_plus4_adaptive_m1_cmp` - - ML restore run: `20260415_142207_pitchTestOrg_plus4_ml_restore_m1` - - result: - - note mel `2.810 -> 3.004` - - env `0.470 -> 0.463` - - entry mel `1.530 -> 2.048` - - exit mel `1.632 -> 1.957` - - onset artifact `0.70 -> 1.06` - - body/core cents stayed `0.00 / -18.32`, matching the base renderer's pitch behavior - - verdict on this case: - - materially worse on the harder truth case even though envelope RMSE moved slightly in the right direction - -Verdict: -- rejected after `M1` -- it failed the equal-weight keep gate: - - the proxy restorer improved `pitchOrg +4` - - but it materially harmed `pitchTestOrg +4` on note mel, entry mel, exit mel, and onset artifact - - there is no justification for `M2` on this proxy family - -What we are keeping: -- the offline benchmark infrastructure stays in the repo -- the live editor remains frozen on `pitch_only_adaptive_selector` -- `FAM-ML-RESTORATION` should only reopen with: - - a trained restorer - - or a materially different restoration method than `ml_restore_proxy_v1` - -### 2026-04-15: `FAM-PVDR` Stop-Fast Verdict - -Purpose: -- execute the next DSP-first family after the ML benchmark stop -- keep `pitch_only_adaptive_selector` as the base editor behavior -- test a genuinely different long-note phase-vocoder overlay instead of the older lightweight boundary-alignment pass - -What was implemented for the trial: -- temporary branch key: - - `pitch_only_pvdr` -- temporary long-upward overlay on top of `pitch_only_adaptive_selector`: - - resample the long upward note region - - apply a custom phase-vocoder stretch back to original duration - - use identity-style peak phase locking around detected spectral peaks - - blend only into the stable long-upward core region -- reused existing phase-lock diagnostics: - - `phaseLockUsed` - - `phaseLockFallbackUsed` - - `phaseAlignedEntry` - - `phaseAlignedExit` - - `phasePeakCount` - -Truth-case results: -- `pitchOrg +4` - - run: `20260415_181645_pitchOrg_plus4_pvdr_p1` - - result: - - byte-identical to the adaptive selector - - SHA stayed `F4BFBE23347CA853EEDE27E48E70F24DC5983AABE37498D02CD8F6D518B3EC72` - - `phaseLockUsed=false` -- `pitchTestOrg +4` - - run: `20260415_181645_pitchTestOrg_plus4_pvdr_p1` - - result: - - note/body/core cents `-628.27 / -628.27 / -664.72` - - onset artifact exploded to `+200512709616936000` - - entry mel `337.641` - - exit mel `324.358` - - `phaseLockUsed=true` - - `phaseAlignedEntry=true` - - `phaseAlignedExit=true` - - `phasePeakCount=14710` - - verdict on this case: - - catastrophic failure on the harder long-upward truth case - -Verdict: -- rejected after `P1` -- stop-fast rule triggered immediately: - - the easy `+4` case did not improve - - the hard `+4` case failed catastrophically - - there was no reason to run the `-4` guards - -Cleanup: -- removed the temporary `pitch_only_pvdr` branch support from the renderer and harness -- kept the existing phase-lock diagnostics only -- the live editor remains `pitch_only_adaptive_selector` - -### 2026-04-15: `FAM-WSOLA-SEAM` Stop-Fast Verdict - -Purpose: -- try the first genuinely untried DSP-first family from the new queue -- keep the existing `pitch_only_adaptive_selector` routing intact and only replace seam handling at short-upward shoulders with a WSOLA-style similarity search -- compare directly against the current adaptive branch on all four canonical truth cases - -What was implemented for the trial: -- temporary branch key: `pitch_only_wsola_seam` -- temporary WSOLA-style shoulder pass layered on top of the adaptive selector: - - mono-sum similarity search - - short-upward notes only - - entry and exit shoulder realignment with a raised-cosine overlap -- reusable seam-search diagnostics added to the native/regression result flow: - - `wsolaUsed` - - `wsolaFallbackUsed` - - `wsolaEntryLagSamples` - - `wsolaExitLagSamples` - - `wsolaCorrelationScore` - -Truth-case results versus the current adaptive selector: -- `pitchOrg +4` - - adaptive control run: `20260415_122753_pitchOrg_plus4_adaptive_cmp` - - WSOLA run: `20260415_121916_pitchOrg_plus4_wsola_w1` - - WSOLA engaged: - - `wsolaUsed=true` - - entry/exit lag `326 / -349` samples - - correlation `0.667` - - result: - - note mel `7.085 -> 7.102` - - env `1.376 -> 1.377` - - entry mel `7.078 -> 7.314` - - exit mel `7.027 -> 7.151` - - onset artifact `1.80 -> 2.29` - - verdict on this case: - - the only case that changed got worse across the seam-sensitive metrics we care about -- `pitchOrg -4` - - adaptive control run: `20260415_122930_pitchOrg_minus4_adaptive_cmp` - - WSOLA run: `20260415_122117_pitchOrg_minus4_wsola_w1` - - result: - - byte-identical output to the adaptive selector - - SHA stayed `6BDCD513FAE4F2267564769D722FCF1FC48E69D981E4493F4E5D2BBBFFD027A0` -- `pitchTestOrg +4` - - adaptive control run: `20260415_123106_pitchTestOrg_plus4_adaptive_cmp` - - WSOLA run: `20260415_122251_pitchTestOrg_plus4_wsola_w1` - - result: - - byte-identical output to the adaptive selector - - SHA stayed `1164BB74D7FC7EC87B163790DAF763E94B1B79812B7BE36F3FCD97966CFA740A` -- `pitchTestOrg -4` - - adaptive control run: `20260415_123320_pitchTestOrg_minus4_adaptive_cmp` - - WSOLA run: `20260415_122513_pitchTestOrg_minus4_wsola_w1` - - result: - - byte-identical output to the adaptive selector - - SHA stayed `9B3E951F0D60DB8828A1CA82AC162A7987EE3C7BA4CCAACCF187B5E75BBD7F37` - -Verdict: -- rejected after `W1` -- it failed the stop-fast gate: - - no primary truth case improved - - the only engaged case (`pitchOrg +4`) regressed on note mel, entry, exit, and onset artifact - - the other three canonical cases stayed exactly on the adaptive baseline - -Cleanup: -- removed the temporary `pitch_only_wsola_seam` renderer branch support after the verdict -- kept the WSOLA/seam diagnostics in the regression/native result flow because they are reusable if a later seam-search family is tried in a meaningfully different form - -### 2026-04-15: `FAM-PHASE-LOCK-PV` Stop-Fast Verdict - -Purpose: -- try the next genuinely untried DSP-first family after the WSOLA stop-fast reject -- keep the existing `pitch_only_adaptive_selector` routing intact and only swap in a boundary-phase-aligned long-upward variant -- compare directly against the current adaptive branch on all four canonical truth cases - -What was implemented for the trial: -- temporary branch key: `pitch_only_phase_lock_pv` -- temporary adaptive-selector variant for long upward notes: - - same adaptive routing as the kept branch - - mono-sum boundary lag search against the simple `ce33` output - - boundary-aligned long-note replacement inside the long upward body span -- reusable phase-lock diagnostics added to the native/regression result flow: - - `phaseLockUsed` - - `phaseLockFallbackUsed` - - `phaseAlignedEntry` - - `phaseAlignedExit` - - `phasePeakCount` - -Truth-case results versus the current adaptive selector: -- `pitchOrg +4` - - adaptive control run: `20260415_122753_pitchOrg_plus4_adaptive_cmp` - - phase-lock run: `20260415_130513_pitchOrg_plus4_phase_lock_p1` - - result: - - byte-identical output to the adaptive selector - - SHA stayed `F4BFBE23347CA853EEDE27E48E70F24DC5983AABE37498D02CD8F6D518B3EC72` - - `phaseLockUsed=false` -- `pitchOrg -4` - - adaptive control run: `20260415_122930_pitchOrg_minus4_adaptive_cmp` - - phase-lock run: `20260415_130647_pitchOrg_minus4_phase_lock_p1` - - result: - - byte-identical output to the adaptive selector - - SHA stayed `6BDCD513FAE4F2267564769D722FCF1FC48E69D981E4493F4E5D2BBBFFD027A0` - - `phaseLockUsed=false` -- `pitchTestOrg +4` - - adaptive control run: `20260415_123106_pitchTestOrg_plus4_adaptive_cmp` - - phase-lock run: `20260415_130820_pitchTestOrg_plus4_phase_lock_p1` - - phase-lock engaged: - - `phaseLockUsed=true` - - `phaseAlignedEntry=false` - - `phaseAlignedExit=true` - - `phasePeakCount=114` - - result: - - note mel `2.81 -> 3.003` - - env `0.47 -> 0.479` - - entry mel `1.53 -> 2.278` - - exit mel `1.632 -> 1.940` - - onset artifact `0.705 -> 0.66` - - verdict on this case: - - selective boundary alignment was real, but it still made the hard long-upward truth case worse where the keep gate matters -- `pitchTestOrg -4` - - adaptive control run: `20260415_123320_pitchTestOrg_minus4_adaptive_cmp` - - phase-lock run: `20260415_131038_pitchTestOrg_minus4_phase_lock_p1` - - result: - - byte-identical output to the adaptive selector - - SHA stayed `9B3E951F0D60DB8828A1CA82AC162A7987EE3C7BA4CCAACCF187B5E75BBD7F37` - - `phaseLockUsed=false` - -Verdict: -- rejected after `P1` -- it failed the stop-fast gate: - - three canonical cases stayed exactly on the adaptive baseline - - the only engaged case (`pitchTestOrg +4`) materially regressed on note mel and entry mel - - the slight onset-artifact gain was not enough to offset the note/entry loss - -Cleanup: -- removed the temporary `pitch_only_phase_lock_pv` renderer branch support after the verdict -- kept the phase-lock diagnostics in the regression/native result flow because they are reusable if a later, materially different phase-coherent family is tried - -### 2026-04-15: `FAM-HPSS-MEDIAN` Stop-Fast Verdict - -Purpose: -- retry HPSS in a genuinely different form from the rejected heuristic shell -- use a real STFT median-filter separation idea instead of the old voiced-mask proxy -- keep the current adaptive selector as the base render and only replace short-upward note regions with a median-shell recombine - -What was implemented for the trial: -- temporary branch key: `pitch_only_hpss_median` -- temporary median-shell pass layered on top of the adaptive selector: - - mono-sum STFT analysis - - horizontal median across time and vertical median across frequency - - scalar harmonic/transient weighting projected back to the time domain - - original signal as the transient side, adaptive output as the harmonic side - - short upward notes only -- reused HPSS diagnostics in the regression flow: - - `hpssUsed` - - `hpssFallbackUsed` - - `harmonicMaskPeak` - - `aperiodicMaskPeak` - - `spectralEnvelopeCorrectionUsed` - -Truth-case results versus the current adaptive selector: -- `pitchOrg +4` - - adaptive control run: `20260415_122753_pitchOrg_plus4_adaptive_cmp` - - median HPSS run: `20260415_132239_pitchOrg_plus4_hpss_median_hm1` - - median HPSS engaged: - - `hpssUsed=true` - - harmonic/aperiodic peaks `0.868 / 0.554` - - result: - - note mel `7.085 -> 7.432` - - env `1.376 -> 1.279` - - entry mel `7.078 -> 7.445` - - exit mel `7.027 -> 6.742` - - onset artifact `1.80 -> 1.48` - - note/body/core cents `0 / 0 / 0 -> -55.55 / -55.55 / -55.55` - - verdict on this case: - - the onset side improved again, but the body pitch and note quality collapsed, so this is not a keepable trade -- `pitchOrg -4` - - adaptive control run: `20260415_122930_pitchOrg_minus4_adaptive_cmp` - - median HPSS run: `20260415_132408_pitchOrg_minus4_hpss_median_hm1` - - result: - - byte-identical output to the adaptive selector - - SHA stayed `6BDCD513FAE4F2267564769D722FCF1FC48E69D981E4493F4E5D2BBBFFD027A0` -- `pitchTestOrg +4` - - adaptive control run: `20260415_123106_pitchTestOrg_plus4_adaptive_cmp` - - median HPSS run: `20260415_132536_pitchTestOrg_plus4_hpss_median_hm1` - - result: - - byte-identical output to the adaptive selector - - SHA stayed `1164BB74D7FC7EC87B163790DAF763E94B1B79812B7BE36F3FCD97966CFA740A` -- `pitchTestOrg -4` - - adaptive control run: `20260415_123320_pitchTestOrg_minus4_adaptive_cmp` - - median HPSS run: `20260415_132746_pitchTestOrg_minus4_hpss_median_hm1` - - result: - - byte-identical output to the adaptive selector - - SHA stayed `9B3E951F0D60DB8828A1CA82AC162A7987EE3C7BA4CCAACCF187B5E75BBD7F37` - -Verdict: -- rejected after `Hm1` -- it failed the stop-fast gate: - - the only engaged case (`pitchOrg +4`) regressed on note mel, entry mel, and body pitch - - the harder `pitchTestOrg +4` case still did not engage - - there is no basis for opening `FAM-HPSS-MEDIAN-SF` on top of this shell - -Cleanup: -- removed the temporary `pitch_only_hpss_median` renderer branch support after the verdict -- kept the HPSS diagnostics in the regression/native result flow because they remain reusable for any future materially different HPSS family - -### 2026-04-15: `FAM-HPSS-SHELL` Stop-Fast Verdict - -Purpose: -- test a genuinely new vertical split family instead of another onset/body handoff -- keep transient/noise content original and send only the harmonic body through a pitched layer -- compare directly against the kept adaptive selector on all four canonical truth cases - -What was implemented for the trial: -- temporary branch key: `pitch_only_hpss_shell` -- temporary HPSS-style island composition on top of the current adaptive selector base: - - original signal for transient/noise-dominant regions - - pitched harmonic layer only in stable voiced regions - - outer-island-only splices -- temporary HPSS diagnostics in the regression flow: - - `hpssUsed` - - `hpssFallbackUsed` - - `harmonicMaskPeak` - - `aperiodicMaskPeak` - - `spectralEnvelopeCorrectionUsed` - -Truth-case results: -- `pitchOrg +4` - - run: `20260415_110453_pitchOrg_plus4_hpss_shell_h1` - - HPSS engaged: `hpssUsed=true` - - onset artifact improved `1.810 -> 1.390` - - but note/body quality collapsed: - - note mel `6.209 -> 10.066` - - env `0.961 -> 2.028` - - entry mel `6.928 -> 10.996` - - exit mel `6.076 -> 15.137` - - verdict on this case: - - same old pattern again: onset-side gain, unacceptable body loss -- `pitchTestOrg +4` - - run: `20260415_110628_pitchTestOrg_plus4_hpss_shell_h1` - - HPSS never engaged: - - `hpssUsed=false` - - output stayed on the existing adaptive-selector result - - SHA `1164BB74D7FC7EC87B163790DAF763E94B1B79812B7BE36F3FCD97966CFA740A` -- `pitchOrg -4` - - run: `20260415_110628_pitchOrg_minus4_hpss_shell_h1` - - note mel regressed to `6.623` - - env `1.102` - - core cents drifted to `+11.90` -- `pitchTestOrg -4` - - run: `20260415_110628_pitchTestOrg_minus4_hpss_shell_h1` - - note mel `3.505` - - onset artifact `3.64` - - not safe enough to justify continuing the family - -Verdict: -- rejected after `H1` -- it failed the stop-fast gate: - - the easy upward case improved only the onset metric while destroying note/body quality - - the harder upward truth case did not engage at all - - the `-4` guards did not stay clean enough - -Cleanup: -- removed the temporary `pitch_only_hpss_shell` renderer branch support after the verdict -- kept the HPSS diagnostic fields in the regression/native result flow because they are reusable if a later, structurally different vertical-split family is tried - -### 2026-04-15: `FAM-ANALYZER-PYIN` Implementation Start - -Purpose: -- make the editor-side monophonic pitch analysis the top active family -- stop pretending we already have pYIN when the repo only had basic YIN-style trackers - -What was already true before this change: -- `PitchAnalyzer` already existed as an offline monophonic editor analyzer -- `PitchDetector` already existed as a live YIN-style tracker -- neither path had: - - true pYIN candidate generation - - temporal decoding - - FFT-accelerated YIN - -What landed in this pass: -- `PitchAnalyzer` now uses: - - Hann windowing - - FFT-derived YIN difference calculation - - CMNDF candidate extraction - - per-frame voiced/unvoiced probability - - lightweight Viterbi-style decoding across frames -- external editor-facing result shape stayed the same: - - frame `frequency` - - frame `midiNote` - - frame `confidence` - - frame `rmsDB` - - frame `voiced` - - existing note segmentation output -- `PitchDetector` comments were corrected so the live tracker is described honestly as YIN-style, not pYIN - -Current status: -- implemented and compiling -- now validated in staged form on the real fixture clips: - - safe default is the direct Hann-windowed YIN difference path plus the new multi-candidate / voiced-probability / Viterbi-style decoder - - FFT-derived difference is still implemented, but remains behind `OPENSTUDIO_ANALYZER_USE_FFT_YIN=1` until parity is proven -- this family is now the top active queue item in the master map - -Real fixture validation: -- `pitchOrg +4` using local analyzer fallback and the correct short-note fixture: - - run: `20260415_100627_pitchOrg_plus4_adaptive_selector_analyzer_pyin_safe_g3` - - note/body/core cents `0.00 / 0.00 / 0.00` - - note mel `7.085` - - entry mel `7.078` - - exit mel `7.027` - - output SHA `F4BFBE23347CA853EEDE27E48E70F24DC5983AABE37498D02CD8F6D518B3EC72` -- first `pitchTest +4` validation looked broken, but that run used the wrong note fixture (`example_plus4_notes.json`, the older `pitchOrg` timing), so it is not a valid analyzer verdict for the later `pitchTest` note family -- `pitchTest +4` using local analyzer fallback and the correct clip-specific fixture: - - run: `20260415_101036_pitchTestOrg_plus4_adaptive_selector_analyzer_pyin_safe_g4` - - note/body/core cents `-17.94 / 0.00 / -18.32` - - note mel `5.158` - - entry mel `1.530` - - exit mel `1.632` - - output SHA `1164BB74D7FC7EC87B163790DAF763E94B1B79812B7BE36F3FCD97966CFA740A` - -FFT probe status: -- explicit FFT-YIN probe on `pitchOrg +4` is still not safe - - run: `20260415_101249_pitchOrg_plus4_adaptive_selector_analyzer_fft_probe_g4` - - note/body/core cents `-386.31 / -386.31 / -401.30` - - note mel `8.072` - - output SHA `019D3F5341440BE010F2B3E5E620AE5878CEAFE5C03364FCBED7DBCC5A4BDDC3` -- verdict: - - keep FFT-derived YIN staged behind env until parity work is done - - keep the direct YIN difference path as the active safe default for the new decoder stack - -Pitch-editor scope change: -- the pitch editor is now intentionally mono-only -- stereo vocal clips remain supported: - - editor analysis mixes the clip to mono for contour extraction - - correction still renders against the full multichannel clip in the backend -- the old polyphonic mode UI path was removed from the pitch editor surface so the product now matches the monophonic editor target directly - -Immediate next action: -- compare note segmentation quality and contour stability against the previous analyzer behavior on `pitchOrg` and `pitchTestOrg` -- only then decide whether the staged analyzer upgrade can become the new baseline - -### 2026-04-14: Post-PSOLA Continuation Checks - -#### Path E1, Iteration 1: Voiced-Tail Continuation Body -- Change: - - tried a voiced-tail continuation source for the short-upward body, seeded from a small epoch window around the entry anchor instead of the full PSOLA body path -- Result: - - `pitchOrg +4` - - failed closed to the trusted `r6` baseline - - output SHA stayed `055B3300041A8B5DE93C462C7E18F180BF7740D0134B0C05B6D255A6A36B46BD` - - body replacement used/fallback `false / true` - - `pitchTest +4` - - also stayed at the same shipping-control output - - output SHA stayed `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - stopped early after the first iteration - - no improvement on the active target and the guard stayed flat, so the path did not earn its second slot - -#### Path E2, Iteration 1: Early Continuation Handoff Into Existing Core -- Change: - - narrowed the continuation idea to the fragile early body only - - replacement region would cover only the first continuation span after voiced entry, then hand into the existing hybrid core - - replacement blending was changed to sit on top of the legacy/own base mix so the handoff could blend into the live core instead of only back into dry legacy -- Result: - - `pitchOrg +4` - - again failed closed to the trusted `r6` baseline - - output SHA stayed `055B3300041A8B5DE93C462C7E18F180BF7740D0134B0C05B6D255A6A36B46BD` - - body replacement used/fallback `false / true` - - `pitchTest +4` - - also remained unchanged on the shipping-control SHA - - output SHA stayed `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - stopped early after the first iteration - - the architecture never engaged on the active target, so there was no justification to spend the second slot - -#### Continuation-Family Conclusion -- Both post-PSOLA continuation families were exhausted under the stop-fast rule. -- Neither one engaged successfully on `pitchOrg +4`. -- The active renderer was restored to the trusted `HS-4 / r6` baseline by disabling body replacement again. -- Current live truth remains: - - `pitchOrg +4` - - note mel `6.209` - - env `0.961` - - entry mel `6.928` - - exit mel `6.076` - - onset artifact `1.810` - - `pitchTest +4` - - note mel `3.481` - - env `0.623` - - entry mel `2.484` - - exit mel `7.662` - - onset artifact `3.819` - -### 2026-04-15: Dormant Branch Truth Sweep - -Purpose: -- finish the option-map analysis for every callable renderer branch already present in the app-path runner -- stop guessing which archived branches might still matter and measure them directly on the same `+4` truth cases - -#### `branch_simple_ce33` -- Result: - - `pitchOrg +4` - - note mel `7.810` - - env `0.883` - - body/core cents `0.00 / 0.00` - - entry mel `7.996` - - exit mel `9.295` - - onset artifact `2.50` - - SHA `29313710B708E393623C18128948110C62375473DC95D46B5F18E1DBF7FB939A` - - `pitchTest +4` - - note mel `3.044` - - env `0.486` - - body cents `0.00` - - entry mel `1.660` - - exit mel `6.378` - - onset artifact `0.72` - - SHA `70A4507DC97B4CC3C630B3DC418798B58E0F2539BF8C6D4EE7F2CEAB012189EC` -- Verdict: - - not the best single overall control because it loses clearly to `CTRL-R6` on `pitchOrg +4` - - but it is the strongest measured standalone result so far on `pitchTest +4` - - keep as a secondary benchmark and harvest candidate - -#### `branch_current_advanced` -- Result: - - `pitchOrg +4` - - note mel `7.329` - - env `0.827` - - body/core cents `-37.23 / -37.23` - - entry mel `7.752` - - exit mel `9.438` - - onset artifact `1.94` - - SHA `90D2D3638B7F1DF5655AD1A9871A3DCEDAC611E5198B285B5039B8583CD7DF45` - - `pitchTest +4` - - note mel `8.784` - - env `1.190` - - body/core cents `-35.70 / -36.45` - - entry mel `5.908` - - exit mel `6.373` - - onset artifact `2.38` - - SHA `E562BC9377B0BFB51B0D139CC532FEDED240B2D636D154166BAC0B1652B1E703` -- Verdict: - - clearly below both controls on truth-case pitch accuracy and note/body quality - - treat as rejected standalone archived branch - -#### `pitch_only_psola_core` -- Result: - - `pitchOrg +4` - - note mel `11.053` - - env `1.640` - - body/core cents `-55.55 / -55.55` - - entry mel `7.448` - - exit mel `8.976` - - onset artifact `1.94` - - SHA `C6E61F58A3A99B41FAE27557AEE69F61EEAFFF925AE72332A2ACF21D54DEBAD8` - - `pitchTest +4` - - note mel `17.536` - - env `2.547` - - body/core cents `-104.96 / -124.35` - - entry mel `6.305` - - exit mel `6.329` - - onset artifact `2.38` - - SHA `A59B8C6305E31D95584C15214B1712F98BFF90B2E764FACA5A69F80A9DF61E6F` -- Verdict: - - catastrophic truth-case miss as a standalone branch - - reject as standalone archived core - -#### `pitch_only_model_core` -- Result: - - `pitchOrg +4` - - note mel `10.955` - - env `1.626` - - body/core cents `-37.23 / -37.23` - - entry mel `7.421` - - exit mel `8.971` - - onset artifact `1.94` - - SHA `E2D3A690881BB489EEC0B7409FD2D60EA87F1AE2FE96527F01DFC89908F57DE8` - - `pitchTest +4` - - note mel `17.397` - - env `2.530` - - body/core cents `-104.96 / -124.35` - - entry mel `6.295` - - exit mel `6.330` - - onset artifact `2.38` - - SHA `FF455AB0B313826E75D249BBF69CEB61F740D741B85BF0DB06771CEAC73289BC` -- Verdict: - - also a strong standalone rejection on the truth cases - - no reason to keep it as an active standalone contender - -#### `pitch_only_own_engine` -- Result: - - `pitchOrg +4` - - note mel `6.316` - - env `1.044` - - body/core cents `0.00 / 0.00` - - entry mel `7.783` - - exit mel `6.407` - - onset artifact `1.59` - - SHA `0A72E7027F69E85741E24AC1CC7CBD3958E263F0ED7FA9F4CEC49EA3D5202EE6` - - `pitchTest +4` - - note mel `12.164` - - env `1.667` - - body/core cents `0.00 / -18.32` - - entry mel `17.634` - - exit mel `13.403` - - onset artifact `3.99` - - SHA `96397D63A5F3AD6F835A80605E9FAF0615947D80E515F5A5F02F844A21AC4146` -- Verdict: - - interesting on the easier `pitchOrg +4` clip because it keeps exact body pitch and improves onset artifact versus `CTRL-R6` - - not viable as a standalone editor because `pitchTest +4` collapses badly - - harvest only if a later v2 needs own-engine core behavior on easier upward notes - -#### `formant_only_own_engine` and `pitch_plus_formant_own_engine` -- Result: - - both matched `branch_current_advanced` bit-for-bit on both `+4` truth cases - - `pitchOrg +4` SHA `90D2D3638B7F1DF5655AD1A9871A3DCEDAC611E5198B285B5039B8583CD7DF45` - - `pitchTest +4` SHA `E562BC9377B0BFB51B0D139CC532FEDED240B2D636D154166BAC0B1652B1E703` -- Verdict: - - treat them as archived advanced/formant variants, not distinct truth-case winners - - reject them as standalone candidates - -#### Dormant-Branch Sweep Conclusion -- The callable archived branch set is now mapped much more clearly: - - strongest `pitchTest +4` branch: `branch_simple_ce33` - - strongest kept overall experimental control: `CTRL-R6` - - strongest easier-clip own-engine result: `pitch_only_own_engine` on `pitchOrg +4` -- The branches that are still worth keeping around as references are: - - `CTRL-SHIP` - - `CTRL-R6` - - `branch_simple_ce33` -- The rest of the dormant callable branches are no longer credible standalone pitch-editor candidates on the truth cases. -- Next queue consequence: - - the callable archived branch analysis is now complete enough to justify moving the top queue item to `FAM-V2-SYNTH-CORE` - - that family is now tracked in the master map as the next upward v2 attempt instead of continuing any exhausted handoff or archived-core loop - -### 2026-04-15: `FAM-V2-SYNTH-CORE` Stop-Fast Check - -Purpose: -- test one new upward `v2` family that was still structurally different from the exhausted handoff and island-core loops -- specifically: - - island shell - - directly synthesized voiced core - - explicit residual layer - - outer-only shell behavior - -Implementation note: -- branch name used for the single iteration was `pitch_only_synth_core` -- the branch code was removed immediately after the verdict, per the prune-on-reject workflow - -#### `G1` -- Result: - - `pitchOrg +4` - - note mel `8.135` - - env `1.517` - - whole/body/core cents `-408.27 / 0.00 / 0.00` - - entry mel `10.468` - - exit mel `13.781` - - onset artifact `1.39` - - island native used/fallback `true / false` - - SHA `247BC954F14147C9ABE0B12F04C20E907BD94C9FD400A5D99905CA995C44E572` - - `pitchTest +4` - - failed closed back to the control output - - note mel `3.481` - - env `0.623` - - entry mel `2.484` - - exit mel `7.662` - - onset artifact `3.82` - - island native used/fallback `false / false` - - SHA `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - decisive reject after the first iteration - - onset improved again on `pitchOrg +4`, but body/entry/exit and whole-note pitch behavior regressed too much - - no improvement and no engagement on `pitchTest +4` - - no `G2` - - branch code removed immediately - -### 2026-04-15: `FAM-ADAPTIVE-SELECTOR` - -Purpose: -- stop forcing one renderer family to solve both truth clips by itself -- harvest the two strongest measured behaviors we now have: - - `CTRL-R6` for short upward notes like `pitchOrg +4` - - `branch_simple_ce33` for long upward notes like `pitchTest +4` - -Implementation: -- new branch: `pitch_only_adaptive_selector` -- rule for this first family: - - short upward notes -> use `CTRL-R6` behavior - - long upward notes -> use `branch_simple_ce33` - - downward notes remain unchanged on the current baseline behavior - -#### `G1` -- Result: - - `pitchOrg +4` - - note mel `6.210` - - env `0.961` - - entry mel `6.978` - - exit mel `6.029` - - onset artifact `1.80` - - SHA `E039C0AF68908D14ECC2BC17A6D335DDDA7C713B6DEFE10BF4D1C59799D3B0E0` - - `pitchTest +4` - - note mel `7.300` - - env `0.840` - - body/core cents `-35.70 / -54.39` - - entry mel `5.911` - - exit mel `6.375` - - onset artifact `2.38` - - SHA `4E0F29A3472F1B0A51A9B5D7D3FB8050DF374FB32034DD20C3049E143192FCAB` -- Diagnosis: - - the “simple” sub-render inside the selector was not actually using the real `branch_simple_ce33` path - - so `G1` was not a fair test of the harvested selector idea - -#### `G2` -- Fix: - - replaced the selector’s long-up simple source with the true whole-file `SignalsmithShifter::processPitchOnlyCe33Base` path used by standalone `branch_simple_ce33` -- Result: - - `pitchOrg +4` - - note mel `6.210` - - env `0.961` - - entry mel `6.978` - - exit mel `6.029` - - onset artifact `1.80` - - SHA `E039C0AF68908D14ECC2BC17A6D335DDDA7C713B6DEFE10BF4D1C59799D3B0E0` - - `pitchTest +4` - - note mel `3.095` - - env `0.493` - - body/core cents `0.00 / -18.32` - - entry mel `1.530` - - exit mel `7.350` - - onset artifact `0.70` - - SHA `F52F4B97CACE7E048767F3243C7DAFD949FD08F2491DDC1CB649B6B8E9B8F24E` -- Guard runs: - - `pitchOrg -4` - - exact same trusted control SHA `6E73A2F0FA1E053488C65DD801D97F1F30564F0684DE076924CF7CCF2C4394AE` - - `pitchTest -4` - - exact same trusted control SHA `1B6A789C4D36AB0D330CD043E41A72EB27B858120DF34B271293764A197F909C` -- Verdict: - - keep - - this is the first family that improved `pitchTest +4` materially without materially harming `pitchOrg +4` - - downward guards remained unchanged, so the branch is safe enough to freeze as the new experimental base for the next queue item - -### 2026-04-15: Initial `-4` Support Scan For The Next Queue - -Purpose: -- start the downward-family work on top of the newly kept adaptive branch -- check whether the archived support branches already contain an obvious `-4` winner we should harvest before designing a new downward-specific hybrid - -#### `branch_simple_ce33` -- Result: - - `pitchOrg -4` - - note mel `8.397` - - env `3.882` - - onset artifact `2.51` - - SHA `C4BCE0BE13F3ED88EEC2B716ECD1C4328244AA57BD884D9927A6976F5815C325` - - `pitchTest -4` - - note mel `3.931` - - env `3.401` - - note/body cents `-5.74 / -5.74` - - entry mel `3.098` - - exit mel `6.106` - - onset artifact `1.07` - - SHA `2732128716D791837D24CD6328B4FD8FA0E4476FC2940EB12FFB4A9FD2A1A600` -- Verdict: - - not a standalone downward winner - - interesting for onset/exit behavior on `pitchTest -4`, but the envelope error is too large to promote directly - -#### `pitch_only_own_engine` -- Result: - - `pitchOrg -4` - - note mel `5.560` - - env `1.544` - - note/body cents `+11.90 / +11.90` - - exit mel `7.135` - - onset artifact `2.79` - - SHA `451786635BD29F262125F7A93355D9267B08CDD8FDA2E8567029FA300C3111AD` - - `pitchTest -4` - - note mel `7.727` - - env `1.049` - - entry mel `10.313` - - exit mel `13.998` - - onset artifact `4.01` - - SHA `88EA8E2FE77F77E4E316B49D362FEC2599C35E48EBA096C40F100F11794978E7` -- Verdict: - - interesting only as a possible easy-clip downward body trait - - not a viable standalone `-4` path because it collapses badly on `pitchTest -4` - -#### Downward-Scan Conclusion -- There is no immediate archived branch we can promote as the downward answer. -- The adaptive selector remains frozen as the active experimental base. -- The next downward family should be a new hybrid that: - - keeps the adaptive branch unchanged for `+4` - - borrows only narrowly proven `-4` traits - - does not replace the whole renderer with either `ce33` or standalone own-engine on downward notes - -### 2026-04-15: `FAM-ADAPTIVE-SELECTOR` Downward Harvest Keep - -Purpose: -- take the best narrow `-4` trait from the support scan -- land it inside the already-kept `pitch_only_adaptive_selector` branch instead of inventing another standalone renderer family - -#### Cleanup Before The Real Test -- I first tried adding a separate branch key for the downward selector experiment. -- That path was not trustworthy: - - the harness label was correct - - but the app still reported `requested / actual = branch_hybrid_reset / branch_hybrid_reset` -- Instead of burning more time on branch-key plumbing, I removed that dead `_down` key and folded the downward trial directly into the working `pitch_only_adaptive_selector` branch. - -#### Iteration G3: Light Short-Downward Own-Engine Support -- Change: - - kept the adaptive selector's upward policy unchanged: - - `CTRL-R6` on short upward notes - - `branch_simple_ce33` on long upward notes - - added one bounded downward trait: - - light own-engine contribution on shorter edited downward notes - - protected entry and exit shoulders - - max own weight `0.24` -- Result: - - `pitchOrg -4` - - note mel `7.405 -> 6.570` - - env `1.094 -> 1.100` - - entry mel `6.902 -> 6.571` - - exit mel `8.778 -> 7.565` - - onset artifact `2.743 -> 2.802` - - SHA `1D75C81C2FC23779F7B07114A30B72B8564E43A638A28276244C84DFEE91F46D` - - `pitchTest -4` - - remained byte-identical to the prior adaptive baseline: - - SHA `1B6A789C4D36AB0D330CD043E41A72EB27B858120DF34B271293764A197F909C` - - note mel `3.775` - - env `1.062` - - entry mel `3.090` - - exit mel `7.134` - - onset artifact `3.642` - - `+4` guards - - both adaptive-selector winners stayed unchanged: - - `pitchOrg +4` SHA `E039C0AF68908D14ECC2BC17A6D335DDDA7C713B6DEFE10BF4D1C59799D3B0E0` - - `pitchTest +4` SHA `F52F4B97CACE7E048767F3243C7DAFD949FD08F2491DDC1CB649B6B8E9B8F24E` -- Verdict: - - keep - - this is the first downward-side improvement that: - - improves a real `-4` truth case - - keeps the harder `pitchTest -4` case safe - - leaves the newly won `+4` adaptive outputs untouched - -#### Current Best Experimental Truth -- `pitchOrg +4` - - note mel `6.210` - - env `0.961` - - entry mel `6.978` - - exit mel `6.029` - - onset artifact `1.804` - - SHA `E039C0AF68908D14ECC2BC17A6D335DDDA7C713B6DEFE10BF4D1C59799D3B0E0` -- `pitchTest +4` - - note mel `3.095` - - env `0.493` - - entry mel `1.530` - - exit mel `7.350` - - onset artifact `0.700` - - SHA `F52F4B97CACE7E048767F3243C7DAFD949FD08F2491DDC1CB649B6B8E9B8F24E` -- `pitchOrg -4` - - note mel `6.570` - - env `1.100` - - entry mel `6.571` - - exit mel `7.565` - - onset artifact `2.802` - - SHA `1D75C81C2FC23779F7B07114A30B72B8564E43A638A28276244C84DFEE91F46D` -- `pitchTest -4` - - note mel `3.775` - - env `1.062` - - entry mel `3.090` - - exit mel `7.134` - - onset artifact `3.642` - - SHA `1B6A789C4D36AB0D330CD043E41A72EB27B858120DF34B271293764A197F909C` - -#### Conclusion -- `pitch_only_adaptive_selector` is now the best overall experimental editor path in the repo: - - short upward notes: - - `CTRL-R6` - - long upward notes: - - `branch_simple_ce33` - - shorter downward notes: - - bounded own-engine support blended into the adaptive output -- The harder `pitchTest -4` problem is not solved yet, but basic downward support is no longer “missing entirely.” - -## Goal And Non-Negotiables -- Match the user reference clips first, not the current engine: - - `pitchOrg.wav -> pitchOrg+4s.wav` - - `pitchOrg.wav -> pitchOrg-4s.wav` - - `pitchTestOrg.wav -> pitchTestOrg+4s.wav` - - `pitchTestOrg.wav -> pitchTestOrg-4s.wav` -- Hard requirements: - - exact local pitch change - - no word/consonant cutoff - - no clipping-like grit - - no previous-note drag - - preview under about `1s` -- Historical safety reference: - - commit `ce33d14f8a11f3315797876eff2711ef71a7cdf0` -- Sonic target: - - user-supplied reference WAVs, even when another render sounds nicer by ear - -## Current Baseline -- Current pitch-only baseline: - - corrected note-local `single` render routing - - safer `ce33`-family carrier - - minimal note-core Stage B -- Why this is the current baseline: - - fixed the major `single` vs `preview_segment` correctness bug - - exact target pitch is now healthy again on the corrected `pitchTest` family - - onset behavior is safer than the old advanced branch - - still fast enough for note-local preview -- Current main pain point: - - `pitchOrg -> +4` still sounds darker / more synthetic than the reference - - neighbor-note metrics are still too high - -### Current Measured Baseline -| Case | Note mel | Note env RMSE | Cents error | Onset jump | Current note-local latency | -| --- | ---: | ---: | ---: | ---: | ---: | -| `pitchOrg -> +4` | `8.299` | `1.018` | `0.00` | `+0.65 dB` | `226 ms` | -| `pitchOrg -> -4` | `7.408` | `1.114` | `0.00` | `+1.64 dB` | `191 ms` | -| `pitchTestOrg -> +4` | `3.449` | `0.620` | `0.00` | `0.00 dB` | `283 ms` | -| `pitchTestOrg -> -4` | `3.769` | `1.071` | `0.00` | `0.00 dB` | `268 ms` | - -- Preview note: - - the bounded note-local preview path has stayed in the same `~200-300 ms` class during harness checks, comfortably under the `<1s` requirement -- Interpretation: - - `pitchTest` correctness is now healthy - - `pitchOrg +4` remains the main sonic gap - -## Reality-Check Reset (2026-04-13) -- Status: - - do **not** treat the current app as research-level or sample-level - - do **not** treat the current own-engine branch as promotion-ready -- Why this reset happened: - - manual listening on the first upward `+4` note move still revealed: - - formant drift - - neighboring-word cutoff / crackle - - robotic shifted-note color - - that means older "close enough" interpretations were too generous -- Fresh frozen report: - - matrix root: `D:\test projects\os tests\reports\pitch-reality-check_20260413_172356` - - matrix markdown: `D:\test projects\os tests\reports\pitch-reality-check_20260413_172356\pitch_reality_check_matrix.md` -- Key findings from the frozen reality-check matrix: - - `baseline_app_current` on `pitchOrg +4` is still far from the reference: - - note mel `8.299` - - entry/exit mel `7.690 / 8.511` - - formant body harmonic drift `0.523` - - low/mid/high band delta `-2.826 / +0.807 / -4.847 dB` - - F1/F2 proxy drift `-23.4 / -46.9 Hz` - - `baseline_own_engine_current` is now branch-distinct and preview-complete, but still not promotion-ready: - - `pitchOrg +4` single / preview are branch-true and parity-safe - - `pitchTest +4` single remains much worse than the app baseline on timbre metrics -- Truth-phase fix that changed the interpretation: - - the old persisted `default` and `pitch_only_own_engine` `+4` hashes were identical because separate app processes could write the same output filename slot - - native regression summaries now persist: - - requested vs actual renderer branch - - fallback reason - - output SHA256 - - candidate coverage start/end - - the output naming path now uses a unique token, so branch comparisons are no longer being corrupted by filename collisions -- Current policy after the reset: - - reference metrics remain the main gate - - but claims of "research/sample-close" are invalid until the upgraded formant-sensitive harness and audition bundle both agree -- Current parity truth after the branch-fix: - - `default`, `pitchOrg +4` - - single / preview parity passed - - note mel delta `0.509 dB` - - note env delta `0.108` - - body/core cents delta `0 / 0` - - `pitch_only_own_engine`, `pitchOrg +4` - - single / preview parity passed - - note mel delta `0.063 dB` - - note env delta `0.194` - - body/core cents delta `0 / 0` - - this removes preview/single parity as the immediate blocker on `pitchOrg +4` - -### Track A: Current App Cleanup After Reset -#### Iteration TA-1: Upward shoulder / entry protection softening -- Hypothesis: - - the first audible failure might be mostly an onset/shoulder problem in the current hybrid-reset app path -- Change: - - made upward-note shoulders longer and drier at note entry/exit -- Result: - - `pitchOrg +4` improved only slightly at the note edges: - - onset peak `+0.65 -> +0.61 dB` - - exit mel `8.511 -> 8.275` - - but `pitchTest +4` regressed clearly: - - note mel `3.449 -> 3.489` - - entry mel `1.941 -> 2.779` - - centroid drift `-39.4 -> -53.9 Hz` -- Verdict: - - rejected -- Learning: - - shoulder tuning alone is not the main blocker on the current app path - -#### Iteration TA-2: Short-upward note-core spectral rebalance -- Hypothesis: - - the current app path might be over-boosting mid bands and over-trimming low/high bands for short upward notes -- Change: - - added a short-upward-only note-core spectral rebalance inside the Stage B envelope-anchor path -- Result: - - effectively neutral on both checked cases -- Verdict: - - rejected -- Learning: - - the current app-path miss is not being driven by a small short-upward Stage B weighting error - -#### Iteration TA-3: Upward carrier swap inside the hybrid app path -- Hypothesis: - - the old `ce33` upward carrier itself is the source of the formant issue, so replacing it with the newer note-local Stage A carrier might immediately improve `+4` -- Change: - - swapped only upward islands in the hybrid branch to the newer note-local carrier while keeping the hybrid blending and Stage B -- Result: - - pitch correctness broke badly: - - `pitchOrg +4` cents `0.00 -> -37.23` - - `pitchTest +4` cents `0.00 -> -35.70` - - timbre metrics did move, but not in a usable product direction -- Verdict: - - rejected and reverted -- Learning: - - the current hybrid branch depends on the `ce33` carrier assumptions more strongly than expected - - fixing the app-path formant issue will need a cleaner carrier replacement plan, not an inline swap - -#### Iteration TA-4: `ce33` carrier with preserve-style formant guidance -- Hypothesis: - - the `ce33` hybrid carrier might be darkening `pitchOrg +4` because it hardcodes `1 / pitchRatio` timbre fallback, so feeding it voiced pitch guidance and preserve-style formant handling could reduce the audible formant drift -- Change: - - routed the `ce33` pitch-only carrier through preserve-mode-style formant handling instead of the old ratio-driven fallback -- Result: - - `pitchOrg +4` regressed badly: - - note mel `8.299 -> 8.833` - - note env `1.018 -> 1.298` - - centroid drift `-189.7 -> +84.8 Hz` - - entry/exit mel `7.690 / 8.511 -> 11.240 / 10.962` - - onset peak `+0.65 -> +2.52 dB` - - `pitchTest +4` also regressed badly: - - note mel `3.449 -> 8.516` - - note env `0.620 -> 1.703` - - centroid drift `-39.4 -> +355.6 Hz` -- Verdict: - - rejected and reverted -- Learning: - - the current hybrid app path is not simply "too dark because `1 / ratio` is wrong" - - moving the `ce33` carrier toward preserve-mode behavior blows up the note body and boundaries instead of fixing the reference mismatch - -#### Iteration TA-5: Short-upward Stage B de-darkening rebalance -- Hypothesis: - - the primary `pitchOrg +4` miss might be recoverable without touching the fragile `ce33` carrier by de-darkening only the short upward Stage B weighting: - - slightly less mid emphasis - - less low trim - - less high-band air protection -- Change: - - added a stronger short-upward-only spectral rebalance inside the hybrid Stage B path -- Result: - - `pitchOrg +4` was mixed: - - note mel `8.299 -> 8.170` - - note env `1.018 -> 1.102` - - centroid drift `-189.7 -> -26.5 Hz` - - entry/exit mel worsened to `9.109 / 9.461` - - harmonic drift worsened `0.523 -> 0.771` - - `pitchTest +4` regressed clearly: - - note mel `3.449 -> 6.366` - - note env `0.620 -> 1.246` - - centroid drift `-39.4 -> +144.5 Hz` -- Verdict: - - rejected and reverted -- Learning: - - stronger short-upward de-darkening can move centroid drift in the right direction on the hard case, but it is too destructive to the healthier clip family - - the next app-path move should not be a broader Stage B spectral push; it needs a cleaner upward carrier or decomposition idea - -#### Iteration TA-6: Upward shoulder protection tightening on the shipping branch -- Hypothesis: - - the stitched / cutoff feeling at the start of upward notes is partly coming from too much wet shoulder exposure in the hybrid-reset shipping branch -- Change: - - for upward notes only, tightened the audible entry/exit shoulders and lowered the wet floor inside the protected entry/exit zones - - kept the voiced body / Stage B timbre logic unchanged -- Result: - - `pitchOrg +4` improved slightly and stayed exact: - - note mel `8.299 -> 8.289` - - note env `1.018 -> 1.017` - - onset peak `+0.65 -> +0.61 dB` - - exit mel `8.511 -> 8.203` - - harmonic drift `0.523 -> 0.522` - - `pitchTest +4` stayed within the guard budget, but did not improve: - - note mel `3.449 -> 3.481` - - note env `0.620 -> 0.623` - - neighbor mel stayed effectively unchanged -- Verdict: - - kept -- Learning: - - the onset / stitching feel can be improved a little without destabilizing the guard case - - but the main miss is still note-core timbre / formant behavior, not shoulders alone - -#### Iteration TA-7: Upward frame-local envelope smoothing -- Hypothesis: - - the upward hybrid-reset app path might still be too broad in its envelope estimate, so tightening the envelope smoothing window could make the correction more local-frame and more reference-like -- Change: - - reduced the upward hybrid-reset envelope smoothing window inside the Stage B anchor pass -- Result: - - `pitchOrg +4` was effectively unchanged at the saved-output level before the guard rerun - - `pitchTest +4` shifted slightly but without any useful improvement -- Verdict: - - rejected and reverted -- Learning: - - the current app-path miss is not being caused by envelope smoothing width alone - -#### Iteration TA-8: Upward core-only high-band detail reinjection -- Hypothesis: - - the app path might still sound dark / robotic on `pitchOrg +4` because the upward hybrid-reset branch is over-smoothing high-band detail inside the voiced core -- Change: - - increased capped upward high-band detail reinjection only inside the hybrid-reset core path -- Result: - - `pitchOrg +4` regressed overall: - - note mel `8.289 -> 8.304` - - note env `1.017 -> 1.021` - - entry/exit mel `7.774 / 8.203 -> 7.829 / 8.238` - - harmonic drift improved only trivially `0.5221 -> 0.5210` - - `pitchTest +4` stayed at the same weaker guard result as the previous rejected branch: - - note mel `3.504` - - note env `0.614` -- Verdict: - - rejected -- Learning: - - extra high-band detail alone is not enough to make the current app path more reference-like - - the remaining app-path gap is more structural than just "too little air" - -## Research Timeline -### Milestone 1: Early DSP tuning before the harness was good enough -- Prior belief: - - broad note mel and general listening were enough to guide tuning -- New evidence: - - onset cracking, note-start weakness, and neighbor damage were still audible even when broad metrics looked acceptable -- Decision: - - stop trusting note-wide metrics alone - -### Milestone 2: Harness upgrade for onset / release / neighbor damage -- Prior belief: - - if note mel improved, pitch quality was probably improving in the right way -- New evidence: - - onset and neighbor windows could fail badly while overall note metrics stayed merely "okay" -- Decision: - - add entry/core/exit windows, pre/post-neighbor windows, onset peak jump, onset high-band burst, and audition bundles - -### Milestone 3: Wrong `pitchTest` fixture discovery -- Prior belief: - - the `pitchTest` comparisons were valid enough to compare branches -- New evidence: - - the old `pitchTest` runs were using the wrong note payload / wrong window family -- Decision: - - add clip-family-specific fixtures for `pitchTestOrg` and stop reusing the older `pitchOrg` note fixture - -### Milestone 4: Preview vs `single` divergence discovery -- Prior belief: - - preview and normal apply were basically the same engine with different quality settings -- New evidence: - - corrected `pitchTestOrg -> +4` preview was around `-36 cents`, while `single` was once around `-282 cents` -- Decision: - - treat the old `single` path as a core product bug, not a minor tuning issue - -### Milestone 5: Note-local `single` render fix -- Prior belief: - - pitch correctness problems were mainly inside Stage A / Stage B DSP -- New evidence: - - most of the giant miss came from how `single` rendered and spliced the edited region -- Decision: - - make `single` use note-local windowing logic consistent with the preview family - -### Milestone 6: Architecture bakeoff -- Prior belief: - - the current advanced branch might simply be the global winner -- New evidence: - - branch results depended strongly on clip family and on whether the corrected fixtures were used -- Decision: - - stop assuming the most complex branch is best; compare branches only through the corrected harness - -### Milestone 7: Safer hybrid becomes the current baseline -- Prior belief: - - a mixed carrier branch might deliver the best of both worlds -- New evidence: - - carrier mixing was unstable after the `single` routing fix -- Decision: - - use the safer `ce33`-family carrier plus a minimal note-core Stage B as the current baseline - -## Approaches Tried -### 1. Simple `ce33`-style Signalsmith baseline -- Intent: - - recover the historically safer articulation path -- Why it seemed promising: - - earlier stable behavior, cleaner entry/exit, fewer synthetic artifacts -- Result: - - exact pitch and cleaner onset behavior in many cases - - still too plain / not close enough to the reference timbre on `pitchOrg` -- Failure / plateau reason: - - safer than richer branches, but often too far from the sonic target -- Status: - - `plateaued but informative` - -### 2. Current advanced note-island Signalsmith branch -- Intent: - - improve the carrier and recover timbre with a serial note-core Stage B -- Why it seemed promising: - - better broad quality than simple `ce33` on `pitchOrg` - - fast preview -- Result: - - good broad note metrics on some cases - - onset and neighbor damage remained - - looked better than it really was before the harness upgrade -- Failure / plateau reason: - - too artifact-prone at note boundaries and not robust enough across clip families -- Status: - - `plateaued but informative` - -### 3. Carrier-mix hybrid (`ce33` shoulders + current core carrier) -- Intent: - - keep safer shoulders while using the stronger current carrier in the core -- Why it seemed promising: - - matched the intuition that shoulders and core want different behavior -- Result: - - unstable after the corrected `single` path - - could blow up centroid drift and quality badly -- Failure / plateau reason: - - mixing two carriers inside one note island is too fragile in the current design -- Status: - - `dead end` - -### 4. Safer hybrid (`ce33` carrier + minimal Stage B) -- Intent: - - keep the safer carrier and add only a small note-core recovery layer -- Why it seemed promising: - - lowest-risk path after the `single` routing fix -- Result: - - exact pitch on corrected `pitchTest` - - safer onset behavior - - good enough to become the current baseline -- Failure / plateau reason: - - still below the research/sample target, especially on `pitchOrg +4` -- Status: - - `current baseline` - -### 5. PSOLA branch -- Intent: - - use a voiced-core pitch-synchronous recovery path -- Why it seemed promising: - - should preserve articulation well in voiced regions -- Result: - - no meaningful app-path win -- Failure / plateau reason: - - did not beat the safer baseline enough to justify the extra complexity -- Status: - - `dead end` - -### 6. Model-style experimental branch -- Intent: - - recover more natural timbre through a heavier local refinement branch -- Why it seemed promising: - - could, in theory, close the realism gap more quickly -- Result: - - no app-path win strong enough to justify it -- Failure / plateau reason: - - not a practical product path in the current form -- Status: - - `dead end for now` - -### 7. Bungee benchmark -- Intent: - - benchmark an interesting outside shifter against Signalsmith -- Why it seemed promising: - - partial wins on some upward note-island cases -- Result: - - interesting benchmark, mixed across directions -- Failure / plateau reason: - - not strong enough or clean enough to replace the current shipping path -- Status: - - `plateaued but informative` - -## Failures And Root Causes -### Failure: word cutoff / broken onset -- Root cause: - - processing too wide a region - - shoulders treated like core - - old `single` render routing bug -- Learning: - - strict note-local rendering is mandatory - -### Failure: clipping-like crackle without true digital clipping -- Root cause: - - interference-style artifacts from overly aggressive blending and unstable branch combinations -- Learning: - - safer carrier + serial correction only - -### Failure: pitch-up sounding brighter, pitch-down darker -- Root cause: - - pitch-directional timbre drift and naive fallback behavior -- Learning: - - up/down probably need distinct note-core handling - -### Failure: branch looked good numerically but sounded wrong -- Root cause: - - harness originally missed onset/release/neighbor artifacts -- Learning: - - decision-making must use artifact-sensitive metrics and short audition files - -### Failure: misleading `pitchTest` conclusions -- Root cause: - - wrong note fixture and wrong analysis window -- Learning: - - every clip family needs its own verified fixture when note placement differs - -## What Actually Improved -- note-island rendering -- corrected `single` render routing -- artifact-aware harness -- simpler `ce33`-family carrier behavior -- smaller, safer Stage B - -## Own-Engine Architecture Branch -- Goal: - - stop building around `generic shifter -> corrective overlays` - - create a shared decomposition engine that can later support: - - `pitch_only_own_engine` - - `formant_only_own_engine` - - `pitch_plus_formant_own_engine` -- What is implemented now: - - new shared-analysis module in: - - `Source/OwnPitchEngine.h` - - `Source/OwnPitchEngine.cpp` - - current analysis output includes: - - note islands - - voiced masks - - F0 track - - epochs - - harmonic model - - residual model - - spectral envelope model - - new experimental branch routing in: - - `Source/PitchResynthesizer.cpp` - - `tools/run-ui-pitch-regression.ps1` - - current mode support: - - `pitch_only_own_engine`: real experimental renderer - - `formant_only_own_engine`: shared-analysis scaffold, legacy render fallback - - `pitch_plus_formant_own_engine`: own pitch base scaffold, legacy formant overlay fallback -- Why this branch exists: - - the old Signalsmith-centered family plateaued below the target - - future global formant work needs a clean place in the engine graph, not another retrofit -- Current status: - - `still viable as architecture` - - `not viable yet as a shipping renderer` - -### First Own-Engine Benchmark Snapshot -| Case | Branch | Note mel | Note env RMSE | Cents error | Centroid drift | Onset jump | Latency | -| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `pitchOrg -> +4` | `default` | `7.288` | `0.789` | `-18.32` | `-115.9 Hz` | `+0.65 dB` | `184 ms` | -| `pitchOrg -> +4` | `pitch_only_own_engine` v1 | `13.917` | `1.840` | `-762.28` | `+349.9 Hz` | `+15.14 dB` | `49 ms` | -| `pitchOrg -> +4` | `pitch_only_own_engine` v2 | `14.169` | `1.893` | `-762.28` | `+355.2 Hz` | `+17.63 dB` | `46 ms` | -| `pitchTestOrg -> +4` | `default` | `3.449` | `0.620` | `0.00` | `-39.4 Hz` | `0.00 dB` | `266 ms` | -| `pitchTestOrg -> +4` | `pitch_only_own_engine` v1 | `19.693` | `2.782` | `0.00` | `+1776.0 Hz` | `+2.29 dB` | `63 ms` | -| `pitchTestOrg -> +4` | `pitch_only_own_engine` v2 | `19.822` | `2.805` | `0.00` | `+1781.8 Hz` | `+4.83 dB` | `61 ms` | - -- Interpretation: - - the own-engine branch is very fast already - - the current synthesis prototype is badly wrong on timbre and onset behavior - - `pitchOrg +4` also has a major pitch-takeover failure in the current prototype - - the small wet-mask fix did not solve the core problem -- Current decision: - - keep the architecture branch - - do **not** treat the current own-engine renderer as competitive yet - - next own-engine work must focus on: - - correct voiced-core takeover - - target-pitch integrity on `pitchOrg +4` - - less synthetic harmonic rendering before any global-formant module is turned on - -### Own-Engine Iteration Log -#### Iteration OE-1: Use the app correction curve as pitch intent -- Change: - - stop deriving target pitch directly from note MIDI fields - - use the same per-sample correction curve the legacy renderer uses -- Why: - - keeps pitch intent consistent across engine families - - avoids fixture-specific note-payload interpretation bugs -- Result: - - `pitchOrg +4` pitch correctness recovered from `-762.28 cents` to `-18.32 cents` - - timbre was still far too bright and synthetic -- Learning: - - the biggest remaining own-engine problem after OE-1 was synthesis color, not pitch-target interpretation - -#### Iteration OE-2: Replace additive carrier with epoch-grain carrier where possible -- Change: - - use a pitch-synchronous grain carrier inside the voiced core when epoch density is good -- Why: - - keeps more of the original waveform character than pure additive synthesis -- Result: - - `pitchTest +4` improved materially: - - note mel `19.478 -> 9.447` - - note envelope `2.819 -> 1.234` - - centroid drift `+2321.8 Hz -> -18.2 Hz` - - `pitchOrg +4` barely changed in that pass -- Learning: - - the new carrier family can work - - clip families do not fail in the same way, so direction and clip behavior matter - -#### Iteration OE-3: Tame core wetness and brightness -- Change: - - lower core wet cap - - make shoulders drier - - add a conservative low-pass to the prototype carrier -- Why: - - the epoch carrier was still too bright and too intrusive in the note body -- Result: - - `pitchOrg +4` improved strongly and became the first genuinely competitive own-engine result: - - note mel `12.012 -> 5.945` - - note envelope `1.989 -> 1.099` - - cents `-18.32 -> 0.00` - - centroid drift `+1323.2 Hz -> +67.1 Hz` - - onset jump `+17.64 dB -> +0.81 dB` - - `pitchOrg -4` was mixed: - - note mel `6.396` - - note envelope `1.154` - - cents `+59.09` - - `pitchTest +4` stayed improved versus the broken prototype, but remained worse than baseline: - - note mel `9.603` - - note envelope `1.144` - - cents `-8.95` - - `pitchTest -4` also remained worse than baseline: - - note mel `8.779` - - note envelope `1.125` - - cents `0.00` -- Learning: - - the own engine is now promising on the hardest `pitchOrg +4` case - - the current prototype is not robust yet across directions and clip families - - next own-engine work should be direction-specific and carrier-shaping-specific, not another broad architecture rewrite - -#### Iteration OE-4: Direction- and duration-aware carrier shaping -- Change: - - separate carrier settings for: - - short upward notes - - longer upward notes - - downward notes -- Why: - - `pitchOrg +4` and the `pitchTest` family were clearly not asking for the same wetness / brightness balance -- Result: - - improved `pitchTest +4` and both `-4` cases - - but softened the best `pitchOrg +4` result too much -- Decision: - - treat the first direction-aware pass as informative, not the final keeper -- Learning: - - direction-aware tuning is necessary - - but the main objective must still protect the `pitchOrg +4` win - -#### Iteration OE-5: Stronger voiced-core takeover with short-downward pitch bias -- Change: - - keep the direction-aware carrier shaping - - add stronger voiced-core takeover - - apply a small target-ratio bias only for short downward notes -- Why: - - the remaining own-engine errors were dominated by partial dry-pitch leakage on `-4` -- Result: - - `pitchOrg -4` cents fixed from `+59.09` to `0.00` - - `pitchTest +4` and `pitchTest -4` stayed exact at `0.00` - - `pitchOrg +4` stayed clearly better than the legacy baseline on note mel, but not as strong as OE-3 -- Learning: - - the own engine now has a credible cross-case baseline - - the next gap is mostly timbre realism / spectral balance, not gross pitch correctness - -#### Iteration OE-6: Longer-upward shoulder easing -- Change: - - keep OE-5 as the base - - reduce dry shoulder protection only for longer upward notes -- Why: - - `pitchTest +4` was still failing more at entry/exit shape than at pitch correctness -- Result: - - `pitchTest +4` improved slightly without harming the other three tracked cases: - - note mel `9.207 -> 9.143` - - note envelope `1.116 -> 1.120` (flat) - - cents stayed `0.00` -- Learning: - - long upward notes do want slightly less dry shoulder protection - - the remaining gap is now a smaller timbre realism problem, not a routing or gross takeover failure - -#### Iteration OE-7: WORLD-style decomposition pass 1 -- Change: - - replace the heuristic own-engine decomposition with a real source-filter pass: - - pitch-adaptive spectral envelope analysis - - harmonic model driven from the source envelope - - band aperiodicity / residual model - - source-filter correction applied on top of the epoch carrier -- Why: - - the own engine was now routing and pitching correctly often enough that the remaining gap looked like a decomposition realism problem, not a carrier-routing problem -- Result: - - this became the first kept decomposition upgrade: - - `pitchOrg +4` improved: - - note mel `6.528 -> 6.293` - - note envelope `1.076 -> 1.049` - - centroid drift `-223.7 Hz -> -139.1 Hz` - - cents stayed `-18.32` - - `pitchTest +4` stayed exact at `0.00 cents`, but timbre remained weak: - - note mel `9.143 -> 9.260` - - note envelope `1.120 -> 1.109` - - centroid drift improved `-435.9 Hz -> -230.7 Hz` - - `pitchTest -4` stayed exact and slightly steadier in centroid: - - note mel `7.915 -> 7.907` - - centroid drift `-247.3 Hz -> -25.4 Hz` -- Learning: - - the source-filter direction is real and worth continuing - - the main remaining miss is now concentrated in long upward note entry/exit and spectral balance, not gross pitch intent - -#### Iteration OE-8: Aperiodicity-aware envelope correction -- Change: - - damp spectral-envelope correction in high-aperiodicity bands - - reduce residual reinjection in noisier regions -- Why: - - the first decomposition pass still looked too blunt on `pitchTest`, especially in regions that behave more like noisy / mixed content than clean harmonics -- Result: - - essentially neutral: - - `pitchOrg +4` note mel `6.293 -> 6.277` - - `pitchOrg -4` note mel `7.033 -> 7.040` - - `pitchTest +4` note mel `9.260 -> 9.265` - - `pitchTest -4` note mel `7.907 -> 7.901` -- Decision: - - rejected as a useful next lever -- Learning: - - the remaining `pitchTest` gap is not mainly caused by over-correcting noisy bins - -#### Iteration OE-9: Fade source-filter correction at core boundaries -- Change: - - fade the source-filter correction and residual reinjection in and out inside the core -- Why: - - the own engine was still trailing the baseline most clearly at note entry and exit on long upward notes -- Result: - - also effectively neutral: - - `pitchOrg +4` note mel `6.277 -> 6.275` - - `pitchTest +4` note mel `9.265 -> 9.259` - - `pitchOrg -4` and `pitchTest -4` moved only trivially -- Decision: - - rejected; keep OE-7 as the current own-engine baseline -- Learning: - - `pitchTest +4` is not primarily failing because the source-filter correction reaches too close to the core edges - - the shared `pitchTest` pre/post-neighbor metrics are also high on the default baseline, so that metric is not the differentiator there - -#### Iteration OE-10: Local-frame spectral-envelope correction -- Change: - - keep the source-filter decomposition from OE-7 - - switch the correction from a single broad note-average envelope to a locally interpolated frame envelope with a small global blend for stability -- Why: - - long upward notes still looked too flattened in timbre, which suggested the correction was too coarse over time rather than fundamentally wrong -- Result: - - this is the new kept decomposition baseline: - - `pitchTest +4` improved: - - note mel `9.260 -> 9.069` - - note envelope `1.109 -> 1.071` - - `pitchTest -4` improved: - - note mel `7.907 -> 7.727` - - note envelope `1.077 -> 1.049` - - `pitchOrg +4` regressed slightly: - - note mel `6.293 -> 6.342` - - note envelope `1.049 -> 1.053` - - `pitchOrg -4` also regressed slightly: - - note mel `7.033 -> 7.060` -- Learning: - - temporal envelope locality matters - - the long-note `pitchTest` gap was not just a carrier problem - - this change is worth keeping even with the small `pitchOrg` regressions because it improves the weaker family materially - -#### Iteration OE-11: Short-upward pitch-ratio bias tuning -- Change: - - add a tiny upward-only pitch-ratio bias for shorter notes, reusing the same structural hook that already fixed the short-downward miss -- Why: - - after OE-10, `pitchOrg +4` still carried the old `-18.32` cents miss even though `pitchTest +4` was exact -- Result: - - the first bias attempt overshot and was rejected - - the reduced bias was kept: - - `pitchOrg +4` cents `-18.32 -> 0.00` - - `pitchOrg +4` note envelope `1.053 -> 1.017` - - `pitchOrg +4` note mel `6.342 -> 6.318` - - `pitchTest +4` stayed unchanged at `9.069` note mel / `0.00` cents - - both `-4` cases stayed unchanged from OE-10 -- Learning: - - the short-upward pitch miss was a small ratio calibration issue, not a deeper decomposition failure - - the own engine now has a meaningfully better `+4` story than it did at OE-7 - -### Latest Own-Engine Snapshot -| Case | Branch | Note mel | Note env RMSE | Cents error | Centroid drift | Onset jump | Latency | -| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `pitchOrg -> +4` | `default` | `7.288` | `0.789` | `-18.32` | `-115.9 Hz` | `+0.65 dB` | `184 ms` | -| `pitchOrg -> +4` | `pitch_only_own_engine (OE-11)` | `6.318` | `1.017` | `0.00` | `-170.6 Hz` | `+0.73 dB` | `57 ms` | -| `pitchOrg -> -4` | `default baseline` | `7.408` | `1.114` | `0.00` | n/a | `+1.64 dB` | `191 ms` | -| `pitchOrg -> -4` | `pitch_only_own_engine (OE-11)` | `7.060` | `1.279` | `0.00` | `+217.3 Hz` | `+0.95 dB` | `54 ms` | -| `pitchTestOrg -> +4` | `default baseline` | `3.449` | `0.620` | `0.00` | `-39.4 Hz` | `0.00 dB` | `266 ms` | -| `pitchTestOrg -> +4` | `pitch_only_own_engine (OE-11)` | `9.069` | `1.071` | `0.00` | `-225.9 Hz` | `0.00 dB` | `74 ms` | -| `pitchTestOrg -> -4` | `default baseline` | `3.769` | `1.071` | `0.00` | n/a | `0.00 dB` | `268 ms` | -| `pitchTestOrg -> -4` | `pitch_only_own_engine (OE-11)` | `7.727` | `1.049` | `0.00` | `-37.9 Hz` | `0.00 dB` | `76 ms` | - -- Current decision after OE-11: - - keep the own-engine branch active - - keep OE-11 as the current own-engine baseline - - OE-8 and OE-9 were neutral and should not be re-tried first - - the own engine is now exact on all four tracked pitch cases - - it is still not ready to replace the current baseline across all clip families - - next work should prioritize: - - improve long upward-note timbre realism on `pitchTest +4` - - bring the `pitchOrg` and `pitchTest` envelope / spectral realism closer together without giving back exact pitch - - compare entry/exit behavior against the default baseline, not just against the reference - - then global-formant field work on top of the stabilized pitch carrier - -### Own-Engine Preview Reality Check -- Fresh current-code preview truth: - - `pitch_only_own_engine` preview no longer times out or drops the result file - - it is still not usable, because the preview render is sonically far away from both the reference and the own-engine single render -- Current `pitchOrg -> +4`: - - `single`: note mel `6.318`, note env `1.017`, note/body/core cents `0.00 / +18.92 / +18.92` - - `preview_segment`: note mel `12.758`, note env `1.434`, note/body/core cents `-458.43 / -880.59 / 0.00` -- Current `pitchTestOrg -> +4`: - - `single`: note mel `9.069`, note env `1.071`, note/body/core cents `0.00 / 0.00 / -18.32` - - `preview_segment`: note mel `16.584`, note env `2.051`, note/body/core cents `-978.55 / -978.55 / -984.54` -- Learning: - - the own-engine preview problem is no longer "job failed" - - it is now a **preview/single parity** problem - - the core of `pitchOrg +4` preview can still hit the right pitch while the broader note body is badly wrong, which points to body/coverage/takeover behavior rather than pure target-pitch math - -#### Iteration OE-12: Short-upward body-takeover increase -- Hypothesis: - - the own-engine upward short-note miss might be caused by too much dry original pitch leaking outside the strict voiced core, especially in preview -- Change: - - increased short-upward body entry/exit wetness and outside-core wet scaling -- Result: - - no measurable change on the checked runs: - - `pitchOrg +4 single`: stayed `6.318 / 1.017 / 0.00 cents` - - `pitchOrg +4 preview`: stayed `12.758 / 1.434 / -458.43 cents` - - `pitchTest +4 single`: stayed `9.069 / 1.071 / 0.00 cents` -- Verdict: - - rejected and reverted -- Learning: - - the current own-engine preview failure is not fixed by a simple increase in short-upward body wetness - - the next own-engine move should target preview/single parity more explicitly, not just "more corrected body" - -## Remaining Approaches -### Phase 1: Timbre Recovery On Safer Hybrid Baseline -- **Iteration budget:** `2` -- Why this could be better: - - pitch correctness is now acceptable - - the remaining gap is mainly note-core timbre realism, especially `pitchOrg +4` - - this is the lowest-risk path because it keeps the corrected note-local routing -- What would count as failure: - - after 2 bounded iterations, `pitchOrg +4` still sounds clearly robotic/dark and metrics do not materially improve -- What happens if it is worse: - - revert the failed timbre change immediately - - keep the safer hybrid baseline unchanged -- **Iterations left after Phase 1 completes:** `6` - -### Phase 2: Direction-Specific Note-Core Correction -- **Iteration budget:** `2` -- Why this could be better: - - `+pitch` and `-pitch` have already shown different needs - - one shared timbre rule is likely holding quality back -- What would count as failure: - - separate up/down tuning gives only tiny wins or causes regressions across clip families -- What happens if it is worse: - - keep only any clearly winning directional constant - - otherwise revert to the safer hybrid baseline -- **Iterations left after Phase 2 completes:** `4` - -### Phase 3: Shoulder / Onset Protection Refinement -- **Iteration budget:** `2` -- Why this could be better: - - the user still reports slight break/cutoff/robotic onset behavior - - the upgraded harness now measures those failures directly -- What would count as failure: - - onset gets cleaner but note-core quality worsens too much - - or metrics improve while the audible issue remains unchanged -- What happens if it is worse: - - lock the best shoulder behavior already found - - do not keep stretching the shoulder region just because one artifact metric improves -- **Iterations left after Phase 3 completes:** `2` - -### Phase 4: Harmonic-Relative Stage B -- **Iteration budget:** `2` -- Why this could be better: - - this is the last serious DSP-family change still likely to move closer to the research renders without another Stage A rewrite - - it targets harmonic structure more directly than the current envelope-style correction -- What would count as failure: - - no meaningful gain after 2 bounded iterations - - or latency / instability / artifacts regress -- What happens if it is worse: - - stop incremental DSP tuning on this family - - do not open another broad branch without a new explicit decision -- **Iterations left after Phase 4 completes:** `0` - -## Iteration Budget And Phase Gates -- Total remaining serious research budget: **`8` bounded iterations** -- Phase breakdown: - - Phase 1: `2` - - Phase 2: `2` - - Phase 3: `2` - - Phase 4: `2` -- At the end of each phase, record: - - what changed - - what improved - - what regressed - - whether the next phase is still justified -- Phase gates: - - start: `8` iterations left - - after Phase 1: `6` iterations left - - after Phase 2: `4` iterations left - - after Phase 3: `2` iterations left - - after Phase 4: `0` iterations left -- Rule: - - no phase gets more than its 2-iteration budget without an explicit reset of the plan - -## Iteration Results -### Phase 1, Iteration 1 of 2: Upward hybrid envelope-anchor relaxation -- Hypothesis: - - `pitchOrg +4` was still too dark, so a lighter upward-only hybrid envelope anchor might recover some timbre without disturbing onset safety -- Change: - - slightly increased upward hybrid mid-band lift - - slightly reduced upward low-band trim - - slightly relaxed upward air protection - - slightly increased upward hybrid detail retention -- Result: - - `pitchOrg -> +4`: note mel `8.414 -> 8.411`, note env `1.019 -> 1.019`, centroid `-194.0 -> -193.3 Hz` - - `pitchTestOrg -> +4`: note mel `3.441 -> 3.439`, note env `0.620 -> 0.621`, centroid `-34.2 -> -33.6 Hz` - - onset metrics stayed effectively unchanged -- Verdict: - - no material improvement - - kept in place because it was neutral-to-slightly-better, but it does not count as a meaningful Phase 1 win - -### Phase 1, Iteration 2 of 2: Stronger upward hybrid Stage B -- Hypothesis: - - a slightly stronger upward Stage B might improve the darker `pitchOrg +4` note core more directly -- Change: - - benchmarked stronger upward Stage B wetness only - - tested `OPENSTUDIO_PITCH_STAGEB_SCALE_UP=0.25` - - tested `OPENSTUDIO_PITCH_STAGEB_SCALE_UP=0.30` -- Result: - - `0.25`: - - `pitchOrg -> +4`: note mel `8.207`, note env `1.018`, centroid `-182.5 Hz` - - `pitchTestOrg -> +4`: note mel `3.549`, note env `0.664`, centroid `-28.9 Hz` - - `0.30`: - - `pitchOrg -> +4`: note mel `8.207`, note env `1.018`, centroid `-182.5 Hz` - - `pitchTestOrg -> +4`: note mel `3.658`, note env `0.699`, centroid `-24.2 Hz` -- Verdict: - - rejected - - the `pitchOrg +4` gain was too small to justify the regression on the already healthier `pitchTestOrg +4` case - -### Phase 1 Status -- Outcome: - - Phase 1 completed without a meaningful win - - current baseline remains the safer hybrid with the neutral Iteration 1 refinement and without the stronger upward Stage B override -- Iterations remaining: - - total remaining after Phase 1 completion: `6` - - next phase to start: `Phase 2: Direction-Specific Note-Core Correction` - -### Phase 2, Iteration 1 of 2: Upward short-note bias inside envelope-anchor strength -- Hypothesis: - - the shorter `pitchOrg +4` note might need stronger upward note-core correction than the longer `pitchTest +4` note -- Change: - - added an upward-only short-note boost inside the envelope-anchor strength calculation -- Result: - - `pitchOrg -> +4`: note mel `8.411 -> 8.410`, note env `1.019 -> 1.019`, centroid `-193.3 -> -193.0 Hz` - - `pitchTestOrg -> +4`: note mel `3.439 -> 3.439`, note env `0.621 -> 0.621`, centroid `-33.6 -> -33.6 Hz` -- Verdict: - - effectively neutral - - kept because it did not hurt anything, but it was too small to matter by itself - -### Phase 2, Iteration 2 of 2: Upward short-note wet-cap bonus -- Hypothesis: - - the stronger upward Stage B setting from Phase 1 helped the short `pitchOrg +4` note but hurt the longer `pitchTest +4` note, so the wet-cap bonus should apply only to short upward notes -- Change: - - added an upward-only wet-cap bonus for shorter edited notes in the safer hybrid branch -- Result: - - `pitchOrg -> +4`: note mel `8.411 -> 8.302`, note env `1.019 -> 1.019`, centroid `-193.3 -> -187.4 Hz` - - `pitchTestOrg -> +4`: note mel `3.439 -> 3.439`, note env `0.621 -> 0.621`, centroid `-33.6 -> -33.6 Hz` - - downward sanity check: - - `pitchOrg -> -4` stayed `7.408 / 1.114 / 0.00 cents` -- Verdict: - - kept - - this is the first meaningful gain that improved the hard upward case without damaging the healthier clip family - -### Phase 2 Status -- Outcome: - - Phase 2 completed with one kept directional improvement - - current baseline now includes the upward short-note wet-cap bonus -- Iterations remaining: - - total remaining after Phase 2 completion: `4` - - next phase to start: `Phase 3: Shoulder / Onset Protection Refinement` - -### Phase 3, Iteration 1 of 2: Drier hybrid shoulders and stronger edge protection -- Hypothesis: - - the safer hybrid still needed slightly more shoulder protection, especially at note boundaries, to reduce onset stress without undoing the Phase 2 timbre gain -- Change: - - shortened audible shoulders for the hybrid branch - - increased entry/exit protection lengths - - lowered hybrid edge wet floors at note entry and exit -- Result: - - `pitchOrg -> +4`: note mel `8.302 -> 8.297`, note env `1.019 -> 1.019`, centroid `-187.4 -> -189.3 Hz`, onset jump `+0.68 -> +0.65 dB` - - `pitchTestOrg -> +4`: note mel `3.439 -> 3.454`, note env `0.621 -> 0.622`, centroid `-33.6 -> -39.1 Hz` -- Verdict: - - kept only because the hard case improved slightly and onset moved in the right direction - - not a strong win - -### Phase 3, Iteration 2 of 2: Longer hybrid entry-side splice fade -- Hypothesis: - - a longer entry-side splice fade might reduce the remaining note-start stress without changing the note core -- Change: - - increased hybrid entry-side splice fade length only -- Result: - - no measurable change relative to Phase 3 iteration 1 on either `pitchOrg +4` or `pitchTestOrg +4` -- Verdict: - - no effect - - the earlier Phase 3 iteration 1 state is the only part worth keeping - -### Phase 3 Status -- Outcome: - - Phase 3 completed without a meaningful onset-specific win - - current baseline keeps the tiny improvement from Phase 3 iteration 1 only -- Iterations remaining: - - total remaining after Phase 3 completion: `2` - - next phase to start: `Phase 4: Harmonic-Relative Stage B` - -### Phase 4, Iteration 1 of 2: More harmonic-focused upward hybrid weighting -- Hypothesis: - - the hybrid upward path was still too broadband, so a more harmonic-focused envelope weight might move the result closer to the reference timbre -- Change: - - reduced the broadband floor of the harmonic weighting for upward hybrid blocks - - increased upward hybrid detail retention slightly -- Result: - - `pitchOrg -> +4`: note mel `8.297 -> 8.303`, note env `1.019 -> 1.018`, centroid `-189.3 -> -190.6 Hz` - - `pitchTestOrg -> +4`: note mel `3.454 -> 3.458`, note env `0.622 -> 0.616`, centroid `-39.1 -> -40.4 Hz` -- Verdict: - - rejected as a meaningful direction - - the change broadened in the wrong way and did not produce a real win - -### Phase 4, Iteration 2 of 2: Narrower harmonic neighborhoods for upward hybrid blocks -- Hypothesis: - - if the broadening was the problem, a narrower harmonic neighborhood might keep correction closer to true harmonics without washing across the note -- Change: - - reverted the broader harmonic weighting from iteration 1 - - narrowed the harmonic-mask sigma for upward hybrid blocks -- Result: - - `pitchOrg -> +4`: note mel `8.297 -> 8.299`, note env `1.019 -> 1.018`, centroid `-189.3 -> -189.7 Hz` - - `pitchTestOrg -> +4`: note mel `3.454 -> 3.449`, note env `0.622 -> 0.620`, centroid `-39.1 -> -39.4 Hz` -- Verdict: - - neutral - - too small to count as a real Phase 4 win - -### Phase 4 Status -- Outcome: - - Phase 4 completed without a meaningful DSP-family breakthrough - - the current frozen fallback baseline is still the safer hybrid family with: - - corrected note-local `single` routing - - short-note upward wet-cap bonus from Phase 2 - - tiny shoulder refinement from Phase 3 -- Iterations remaining: - - total remaining after Phase 4 completion: `0` - - next state: `freeze fallback baseline and hand off unless a new plan is created` - -## Decision Gates -- Continue on the current path only if: - - pitch remains within `+/-5 cents` - - no word cutoff or crackle returns - - `pitchOrg +4` timbre improves materially by ear and metrics - - preview remains under `1s` -- Freeze the current safer hybrid as fallback baseline if: - - later phases fail to improve timbre materially - - but the baseline still preserves pitch correctness, locality, and onset safety -- Escalate / hand off to another agent if: - - all `8` remaining iterations are used - - and the output is still clearly below the user reference by ear - - especially if `pitchOrg +4` remains robotic/dark - -## Failure / Handoff Plan -- When iterations reach `0`: - - stop doing more parameter loops - - freeze the best fallback baseline - - hand off with: - - current best baseline branch - - harness commands - - winning and losing metrics - - dead ends not worth retrying first - - open hypotheses for the next agent -- Handoff framing: - - the product path is now functionally corrected - - the remaining gap is mainly sonic realism, especially on upward note-local shifts - - the next agent should start from the corrected note-local baseline, not the old broken path - -## Bottom Line -- The biggest hidden bug was not just DSP quality; it was the mismatch between preview-style note-local rendering and the old `single` apply path. -- After fixing that, the project moved to a much safer baseline. -- We are no longer mainly fighting gross pitch correctness on the later `pitchTest` note. -- The remaining work is bounded: - - `8` serious iterations left - - `4` remaining approach families - - after that, freeze the best baseline and hand off cleanly instead of looping indefinitely. - -## Persistent Comparison Harness -- Canonical persistent artifact root: - - `D:\test projects\os tests` -- Canonical manifest: - - `D:\test projects\os tests\manifests\pitch_artifacts.json` -- Best saved app/own-engine artifacts are now registered in the manifest instead of being left in temp folders. -- New comparison tooling: - - `tools/pitch_harness_common.ps1` - - `tools/register-pitch-artifact.ps1` - - `tools/pitch_spectrogram_compare.py` - - `tools/run-pitch-output-comparison.ps1` -- Verified persistent comparison reports: - - `pitchOrg +4` - - matrix: `D:\test projects\os tests\reports\pitch_output_comparison_20260413_160935.md` - - report dir: `D:\test projects\os tests\reports\pitchOrg__4\20260413_160935` - - `pitchTestOrg +4` - - matrix: `D:\test projects\os tests\reports\pitch_output_comparison_20260413_161412.md` - - report dir: `D:\test projects\os tests\reports\pitchTestOrg__4\20260413_161412` -- Research-reference comparison is now handled explicitly by the harness: - - if a research render is not registered in the manifest, the report records that as missing instead of silently skipping it -- Current comparison finding to keep in mind: - - for the saved `+4` reports, the current `default` and `pitch_only_own_engine` candidates came out numerically identical in the stored comparison runs, so branch routing needs to be re-checked before treating those as meaningfully distinct outputs - -## April 13 Reality-Check Follow-Through - -### Truth Refresh -- Branch-truth and output-truth were re-verified through the real app path. -- Current shipping fallback remains: - - `default` -> actual branch `branch_hybrid_reset` -- Current hard-case app baseline remains: - - `pitchOrg +4`: note mel `8.299`, env `1.018`, cents `0.00`, pre/post-neighbor mel `8.017 / 4.235`, onset peak `+0.649 dB`, harmonic drift `0.523` -- Current downward app baseline remains: - - `pitchOrg -4`: note mel `7.405`, env `1.094`, cents `0.00`, onset peak `+1.416 dB`, harmonic drift `0.469` -- Current guard-case app baseline remains: - - `pitchTest +4`: note mel `3.449`, env `0.620`, cents `0.00`, pre/post-neighbor mel `15.956 / 19.285` - -### Harness Upgrade: Stutter-Sensitive Metrics -- Added new onset artifact diagnostics to `tools/reference_audio_match.py`: - - `onsetDerivativeJumpDb` - - `onsetRepeatSimilarityExcess` - - `onsetSpectralFluxDelta` - - `onsetArtifactScore` -- These are now carried through: - - `tools/run-ui-pitch-regression.ps1` - - `tools/run-pitch-reality-check.ps1` -- Purpose: - - make the stitched / repeated-attack / stutter-like onset failure visible in saved reports instead of relying only on broad entry-window metrics - -### App Iteration A: Onset-Safe Splice Relocation -- Hypothesis: - - anchoring the wet handoff later for upward notes would reduce the stitched/stutter feel at note start -- Change: - - first tried voiced-onset-anchored handoff - - then tried a small mandatory minimum delay for the upward wet ramp -- Result: - - both variants produced no measurable output change on the real app path - - `pitchOrg +4` stayed: - - note mel `8.289` - - env `1.017` - - onset peak `+0.61 dB` - - onset derivative / repeat / flux / artifact `+1.84 / 0.000 / +1.50 / +1.84` - - `pitchTest +4` stayed: - - note mel `3.481` - - env `0.623` - - onset derivative / repeat / flux / artifact `0.00 / 0.636 / +0.59 / +3.82` -- Verdict: - - rejected - - the current app-path onset problem is real, but this splice-only tweak was not strong enough to move the rendered output - -### App Iteration B: Dedicated Downward App Law -- Hypothesis: - - downward pitch shifts need their own low/mid/high envelope law and should not share the same timbre correction as upward shifts -- Change: - - tested a downward-only hybrid Stage B weighting change: - - stronger low-harmonic relocation - - different air protection - - lower downward detail preservation -- Result: - - no meaningful win worth keeping - - `pitchOrg -4` stayed effectively at baseline: - - note mel `7.405` - - env `1.094` - - onset derivative / repeat / flux / artifact `+2.74 / 0.000 / +0.74 / +2.74` - - `pitchTest -4` remained around the old level and still not acceptable by ear: - - note mel `3.775` - - env `1.062` - - onset derivative / repeat / flux / artifact `0.00 / 0.607 / +1.00 / +3.64` -- Verdict: - - rejected - - the current app branch appears to be at its ceiling for these small downward-law pushes - -### App-Path Status After Final Two Micro-Iterations -- Outcome: - - the harness is now better at agreeing with the audible onset complaint - - the two final app-path micro-iterations did not produce a keepable improvement - - shipping app baseline remains the last kept `TA-6` shoulder-protection state -- App-path iterations left: - - `0` -- Decision: - - stop further micro-tuning on the current app branch - - pivot active recovery to the own-engine / source-filter path for the next structural work - -## Own-Engine Recovery Restart - -### Own-Engine Baseline Before Restart -- Fresh own-engine truth runs showed: - - `pitchOrg +4` - - note mel `6.484` - - env `1.302` - - cents `+18.92` - - harmonic drift `0.423` - - onset artifact `1.76` - - `pitchOrg -4` - - note mel `7.307` - - env `1.634` - - cents `-46.79` - - harmonic drift `0.320` - - onset artifact `2.79` -- Working conclusion: - - the own engine was already more promising than the shipping app on some timbre metrics - - but it was still not trustworthy because pitch correctness had drifted again - -### Own-Engine Iteration OE-R1: Upward Onset-Safe Wet Handoff -- Hypothesis: - - the own-engine wet mask was handing too much synthesized content into the perceptual note onset, causing entry roughness and obscuring the core renderer quality -- Change: - - for upward notes only, made the own-engine body entry much drier at the start of the note - - added a short dry-hold inside the note body before the wet ramp rises - - left downward onset handling unchanged -- Result: - - `pitchOrg +4` - - note mel `6.484 -> 6.530` - - env `1.302 -> 1.356` - - cents `+18.92 -> 0.00` - - harmonic drift `0.423 -> 0.390` - - centroid `-310.7 -> -279.2 Hz` - - `pitchOrg -4` - - note mel `7.307 -> 5.560` - - env `1.634 -> 1.544` - - cents `-46.79 -> +11.90` - - harmonic drift `0.320 -> 0.176` - - `pitchTest +4` - - remained effectively unchanged: - - note mel `9.072` - - env `1.075` - - core cents `-18.32` - - `pitchTest -4` - - stayed exact on pitch: - - note mel `7.727` - - env `1.049` - - cents `0.00` -- Verdict: - - kept as the new experimental own-engine baseline - - this is the first structural own-engine iteration after the app-path stop point that materially improved both `pitchOrg +4` and `pitchOrg -4` - - it still does not beat the shipping app on the `pitchTest` family, so it remains experimental only - -### Own-Engine Iteration OE-R2: Stronger Long-Upward Source-Filter Correction -- Hypothesis: - - `pitchTest +4` is a long upward note, so the own-engine source-filter correction for long bodies might simply be too weak and too broad -- Change: - - increased long-upward local envelope correction strength - - tightened the gain limits and high-band cap for long upward notes only -- Result: - - `pitchTest +4` - - stayed essentially unchanged: - - note mel `9.070` - - env `1.073` - - core cents `-18.32` - - onset artifact `4.01` - - `pitchOrg +4` - - stayed effectively unchanged and healthy for the current own-engine baseline: - - note mel `6.520` - - env `1.352` - - cents `0.00` -- Verdict: - - rejected - - `pitchTest +4` is not mainly blocked by a simple long-upward correction-strength issue in the current source-filter step - -### Own-Engine Iteration OE-R3: Long-Upward Drier Shoulders -- Hypothesis: - - the long upward note in `pitchTest +4` might still be failing because too much synthesized signal is entering the body shoulders, not because the core timbre model is weak -- Change: - - made long upward notes use a drier entry shoulder, longer exit protection, and lower outside-core wetness -- Result: - - `pitchOrg +4` - - improved onset-facing metrics a bit: - - onset peak `+0.73 -> +0.49 dB` - - onset artifact `1.76 -> 1.59` - - pitch stayed exact - - `pitchTest +4` - - essentially unchanged: - - note mel `9.070` - - env `1.072` - - onset artifact `3.99` -- Verdict: - - rejected as the next baseline - - useful for diagnosis because it showed `pitchTest +4` is not mainly a shoulder wetness problem - -### Own-Engine Iteration OE-R4: Long-Upward Tighter Core Window -- Hypothesis: - - if `pitchTest +4` is not a wet-mask problem, its synthesized core may still be too wide and invading phonetic shoulders before epoch rendering begins -- Change: - - increased long-upward core entry/exit protection inside the shared analysis stage -- Result: - - `pitchTest +4` - - did not improve: - - note mel `9.084` - - env `1.079` - - onset artifact `4.01` - - `pitchOrg +4` - - stayed unchanged and healthy relative to the current own-engine baseline -- Verdict: - - rejected - - `pitchTest +4` is not mainly blocked by a simple long-note core-window size issue - -### Own-Engine Budget Status -- Used in the current structural own-engine cycle: - - `4` bounded iterations - - `OE-R1` kept - - `OE-R2` rejected - - `OE-R3` rejected - - `OE-R4` rejected -- Remaining serious own-engine iterations in the current budget: - - `2` -- Current best experimental own-engine baseline remains: - - `OE-R1`: upward onset-safe wet handoff - -### Own-Engine Iteration OE-R5: Long-Upward Epoch-Carrier Remap -- Hypothesis: - - long upward notes may sound repetitive because extra target epochs are being matched to the nearest discrete source epoch instead of interpolating between source epochs -- Change: - - for long upward notes only, interpolated the epoch carrier between adjacent source epochs instead of hard-rounding to one source epoch -- Result: - - `pitchTest +4` - - mixed: - - note mel `9.085` - - env `1.077` - - harmonic drift improved to `0.221` - - but onset/body quality did not improve enough - - `pitchOrg +4` - - unchanged and still exact -- Verdict: - - rejected - - useful because it confirmed the long-note upward miss is partly carrier-related, but not solved by epoch interpolation alone - -### Own-Engine Iteration OE-R6: Long-Upward Harmonic-Carrier Split -- Hypothesis: - - the long-note upward miss might need a different carrier family altogether; keep short-note upward and downward notes on the stronger path, but force long upward notes onto the harmonic core renderer -- Change: - - long upward notes bypass the epoch carrier and use the harmonic carrier path instead -- Result: - - `pitchTest +4` - - first meaningful improvement on this clip family in this bounded cycle: - - note mel `9.084 -> 8.785` - - env `1.079 -> 1.045` - - harmonic drift `0.290 -> 0.232` - - `pitchOrg +4` - - unchanged and still strong for the current own-engine baseline: - - note mel `6.520` - - env `1.352` - - cents `0.00` - - `pitchOrg -4` - - unchanged -- Verdict: - - kept - - this becomes the new experimental own-engine baseline - -### Own-Engine Final Budget Status -- Used in the current bounded structural own-engine cycle: - - `6` serious iterations - - kept: - - `OE-R1` - - `OE-R6` - - rejected: - - `OE-R2` - - `OE-R3` - - `OE-R4` - - `OE-R5` -- Remaining serious own-engine iterations in the current budget: - - `0` -- Current best experimental own-engine baseline is now: - - `OE-R6`: long-upward harmonic-carrier split on top of the earlier upward onset-safe handoff - -## April 13, 2026: Hybrid Structural Branch For `pitchTestOrg` - -### Goal -- Start the next recovery cycle on the user-confirmed truth case: - - original: `D:\test projects\pitchTestOrg.wav` - - references: - - `D:\test projects\pitchTestOrg+4s.wav` - - `D:\test projects\pitchTestOrg-4s.wav` -- Freeze the current shipping app path as control and route new work into an explicit experimental branch: - - `pitch_only_hybrid_structural` - -### Harness / Branch Infrastructure -- Added explicit experimental branch routing: - - requested / actual branch name: - - `pitch_only_hybrid_structural` -- Updated the UI regression runner so this branch can be exercised through the same persistent app-path harness and reports. - -### Hybrid Structural Iteration HS-1: Voiced-Onset / Voiced-Exit Anchored Wet Mask -- Hypothesis: - - the `pitchTest` truth case is failing because the pitch renderer is entering and exiting too close to the perceptual attack and handoff, creating stitched boundaries -- Change: - - added a branch-specific experimental route - - added voiced-onset / voiced-exit detection support in the own-engine wet-mask path - - first experimental mask delayed wet entry and advanced wet exit around detected voiced activity -- Result: - - `pitchTest +4` - - got much worse: - - note mel `11.889` - - env `1.620` - - entry mel `15.982` - - exit mel `12.982` - - `pitchTest -4` - - also underperformed the shipping control badly: - - note mel `7.718` - - env `1.044` - - entry mel `10.380` - - exit mel `14.244` -- Verdict: - - rejected - - diagnosis: - - simply keeping the shoulder drier is not enough - - on this truth case, the shoulder still needs pitch-rendered content rather than a long dry hold - -### Hybrid Structural Iteration HS-2: Downward-Specific Source-Filter Law -- Hypothesis: - - `pitchTest -4` is a true downward timbre problem and needs a separate branch-specific low/mid/high correction law -- Change: - - added branch-specific downward source-filter parameters for: - - correction strength - - gain limits - - local envelope blend - - residual reinjection -- Result: - - `pitchTest -4` - - only tiny movement, still far from control: - - note mel `7.727` - - env `1.045` - - exit mel `14.072` - - `pitchTest +4` - - regressed further: - - note mel `12.181` - - env `1.671` - - entry mel `18.285` -- Verdict: - - rejected - - diagnosis: - - a downward-specific law is necessary eventually - - but not on top of the current failed shoulder/core handoff variant - -### Isolation Cleanup -- The hybrid-structural behavior is now explicitly isolated to the `pitch_only_hybrid_structural` branch so the older own-engine route is not unintentionally affected by these failed experiments. - -### Current State After This Cycle -- Shipping control remains the current app baseline. -- `pitch_only_hybrid_structural` exists as a distinct experimental branch in the harness. -- No keepable quality win was found yet on the user-confirmed `pitchTestOrg +/-4` truth case. -- Most important learning: - - this clip family is not improved by a simple "drier shoulders" move - - the next structural attempt must preserve more pitch-rendered content at the note entry while still making the splice behavior smoother - -### Hybrid Structural Iteration HS-3: Short-Upward Blend Cap And Later Ramp -- Hypothesis: - - on the hybrid branch, the short-upward own-engine blend was still too aggressive across the full core, so we were leaving performance on the table at the note attack and handoff -- Change: - - kept the existing short-upward-only hybrid blend policy - - increased entry/exit protection inside the hybrid blend mask: - - entry `40 ms -> 50 ms` - - exit `50 ms -> 60 ms` - - capped own-engine contribution inside the short-upward core at `0.82` instead of fully replacing the legacy carrier -- Result: - - `pitchOrg +4` - - meaningful improvement over the previous kept hybrid baseline: - - note mel `6.252 -> 6.214` - - env `1.019 -> 0.966` - - entry mel `7.429 -> 7.069` - - exit mel `6.393 -> 6.076` - - onset artifact rose only slightly: - - `1.753 -> 1.796` - - `pitchTest +4` - - preserved exact shipping-control behavior by SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` - - `pitchTest -4` - - preserved exact shipping-control behavior by SHA: - - `1B6A789C4D36AB0D330CD043E41A72EB27B858120DF34B271293764A197F909C` -- Verdict: - - kept - - new experimental hybrid baseline -- Learning: - - the hybrid branch is now doing the right thing structurally: - - it stays pinned to the shipping control on the user-confirmed `pitchTest` truth case - - while still allowing bounded short-upward improvements on `pitchOrg +4` - - next short-upward work should improve onset smoothness without giving back the note-body gain - -### Hybrid Structural Iteration HS-4: Softer Early-Core Short-Upward Blend -- Hypothesis: - - the stitched feel is no longer coming from the fully dry attack itself, but from the first few milliseconds after the hybrid branch enters the rendered core -- Change: - - left the hybrid branch pinned to shipping-control behavior for long and downward notes - - within the short-upward path only: - - kept the later `50 ms` entry and `60 ms` exit protection from `HS-3` - - reduced the first `20 ms` of the rendered core to a lower own-engine blend cap (`0.62`) before ramping to the full short-upward cap (`0.82`) -- Result: - - `pitchOrg +4` - - another small but real gain over `HS-3`: - - note mel `6.214 -> 6.202` - - env `0.966 -> 0.964` - - entry mel `7.069 -> 6.788` - - harmonic drift `0.4001 -> 0.3997` - - onset artifact did not improve yet: - - `1.796 -> 1.840` - - `pitchTest +4` - - still preserved exact shipping-control behavior by SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - kept as the new experimental hybrid baseline - - but with a caution flag: - - this improved the short-upward note match again without touching the truth-case fallback - - it did not solve the onset-artifact score, so the next step still needs to be onset-focused rather than more broad note-body tuning - -### Hybrid Structural Iteration HS-5: Post-Core Legacy Hold -- Hypothesis: - - the stitched onset might be caused by the hybrid branch entering the early rendered core too soon, so holding the very start of the core on the legacy carrier for a few extra milliseconds could reduce the splice artifact -- Change: - - inserted an additional `8 ms` post-core legacy hold before the short-upward hybrid branch began ramping into the early-core own-engine blend -- Result: - - `pitchOrg +4` - - mixed and too small to keep: - - note mel `6.202 -> 6.209` (worse) - - env `0.964 -> 0.961` (better) - - entry mel `6.788 -> 6.928` (worse) - - onset artifact `1.841 -> 1.809` (slightly better) - - `pitchTest +4` - - still preserved exact shipping-control behavior by SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected - - reverted to `HS-4` baseline -- Learning: - - delaying the rendered-core handoff later by itself is not enough - - the remaining onset problem is probably about the transition shape between legacy and hybrid content, not simply "start later" - -### Hybrid Structural Iteration HS-6: Onset-Side Blend Smoothing -- Hypothesis: - - the remaining short-upward onset artifact might come from a derivative spike at the legacy/hybrid boundary, so a tiny post-blend smoothing pass over the first `12 ms` after hybrid entry could soften the splice without changing note-body behavior -- Change: - - added an onset-only smoothing pass on the already-blended short-upward output, applied only over the first `12 ms` after the hybrid branch enters the short-upward rendered core -- Result: - - `pitchOrg +4` - - mixed and not good enough to keep: - - note mel `6.202 -> 6.226` (worse) - - env `0.964 -> 0.960` (better) - - entry mel `6.788 -> 6.804` (worse) - - onset artifact `1.841 -> 1.822` (slightly better) - - harmonic drift `0.3997 -> 0.3948` (better) - - `pitchTest +4` - - still preserved exact shipping-control behavior by SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected - - reverted to `HS-4` baseline -- Learning: - - onset-only waveform smoothing can shave a little off the artifact score, but it is too expensive in note match if applied this bluntly - - the next onset fix likely needs a smarter transition law between legacy and hybrid content, not a generic local smoothing pass - -### Hybrid Structural Iteration HS-7: Coherence-Aware Onset Weighting -- Hypothesis: - - the onset artifact might come from the hybrid branch using too much own-engine signal in moments where the legacy and own waveforms disagree sharply, so early onset-side blend should be reduced only when the two signals are locally incoherent -- Change: - - added a short-upward-only onset-focus mask over the first `18 ms` after hybrid core entry - - inside that onset window only, modulated the hybrid blend by a local coherence score derived from: - - slope agreement between legacy and own signals - - instantaneous amplitude agreement -- Result: - - `pitchOrg +4` - - collapsed to the same mixed outcome as the prior rejected onset-law attempt: - - note mel `6.202 -> 6.209` (worse) - - env `0.964 -> 0.961` (better) - - entry mel `6.788 -> 6.928` (worse) - - onset artifact `1.841 -> 1.809` (slightly better) - - `pitchTest +4` - - still preserved exact shipping-control behavior by SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected - - reverted to `HS-4` baseline -- Learning: - - a simple local coherence heuristic is still not changing the transition in the right way - - the next real onset attempt likely needs to operate on the transition curve or phase relationship more fundamentally, not just attenuate the blend where mismatch is detected - -### Hybrid Structural Iteration HS-8: Phase-Aligned Onset Bridge Scaffold -- Hypothesis: - - the remaining stitched onset might come from a phase-misaligned handoff between legacy and hybrid content, so replacing the short-upward scalar onset law with a local aligned bridge could reduce the stutter without disturbing the proven guard-case fallback -- Change: - - added hybrid onset-bridge scaffolding in `PitchResynthesizer.cpp`: - - local alignment search - - bridge-safe RMS matching - - bridge diagnostics plumbed through native regression results - - also updated the regression result merge so bridge diagnostics now survive into saved JSON/Markdown reports -- Result: - - first active bridge attempt on `pitchOrg +4` was clearly not keepable: - - note mel `6.202 -> 6.392` (worse) - - env `0.964 -> 1.006` (worse) - - entry mel `6.788 -> 9.951` (much worse) - - onset artifact `1.841 -> 1.810` (slightly better only) - - bridge diagnostics finally reported truthfully: - - `bridge used / fallback: true / false` - - `bridge lag / score / gain: 23 / 0.759 / -1.5 dB` - - `pitchTest +4` - - stayed pinned to shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected as an active renderer change - - bridge activation is now disabled again, while the diagnostics plumbing stays in the repo - - active experimental baseline restored to the last known-good `HS-4 / r6` behavior -- Reconfirmed active baseline after disabling bridge activation: - - `pitchOrg +4` - - note mel `6.209` - - env `0.961` - - entry mel `6.928` - - onset artifact `1.810` - - `bridge used / fallback: false / true` - - `pitchTest +4` - - unchanged shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Learning: - - explicit onset-bridge diagnostics were worth adding - - the first bridge formulation confirmed the real problem is not “whether to align,” but “how to enter the aligned content without blowing up entry match” - - the next viable onset attempt should not replace the whole onset blend law at once; it should treat alignment as a constrained adjustment inside the already-good `r6` blend family - -### Hybrid Structural Iteration HS-9: Constrained Onset Alignment Assist -- Hypothesis: - - a full onset bridge was too aggressive, but a tiny alignment-only assist inside the existing `r6` early-core ramp might improve the stitched feel without disturbing the proven body/guard-case behavior -- Change: - - tried using local onset alignment only as a helper inside the existing short-upward `r6` blend law, rather than replacing that law -- Result: - - after rebuilding cleanly, the constrained assist was a true no-op: - - `pitchOrg +4` - - note mel stayed `6.209` - - env stayed `0.961` - - entry mel stayed `6.928` - - onset artifact stayed `1.810` - - `pitchTest +4` - - still preserved exact shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected - - cleaned back out so the branch stays on the real `HS-4 / r6` baseline -- Learning: - - the current onset issue is not being solved by small alignment assists layered onto the existing short-upward ramp - - the next meaningful improvement will likely need a different short-upward transition model entirely, not another tiny variant of the same ramp - -### Hybrid Structural Iteration HS-10: Four-Zone Short-Upward Transition Scaffold -- Hypothesis: - - replacing the old short-upward scalar ramp with an explicit four-zone onset/exit model might improve the stitched feel without touching long notes or downward notes -- Change: - - replaced the short-upward `r6` ramp with a four-zone model: - - dry shoulder - - transition pre-core - - stabilized early-core - - separate exit taper - - kept bridge rendering disabled and kept `pitchTest +4` on the same long-note fallback route -- Result: - - `pitchOrg +4` - - not keepable: - - note mel stayed flat at `6.209` - - env worsened `0.961 -> 0.963` - - entry mel worsened `6.928 -> 7.019` - - exit mel worsened `6.076 -> 6.310` - - onset artifact worsened `1.810 -> 1.840` - - `pitchTest +4` - - remained unchanged on the shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected -- Learning: - - the first explicit transition-model scaffold was too dry at the start and too weak at the handoff back out - - simply breaking the ramp into named zones did not improve the actual onset problem - -### Hybrid Structural Iteration HS-11: Onset-Confidence Preset Tables -- Hypothesis: - - the new short-upward transition model might need different preset tables based on onset strength, with shorter/softer notes entering the hybrid content earlier than the default table -- Change: - - added internal `high / medium / soft` onset classification - - then tried a soft-biased preset for short, non-spiky upward notes -- Result: - - `pitchOrg +4` - - the first classifier pass stayed on the same losing table as HS-10 - - after biasing short notes toward the soft table, the result still got worse: - - note mel `6.209 -> 6.215` - - env `0.961 -> 0.974` - - entry mel `6.928 -> 7.140` - - exit mel `6.076 -> 6.306` - - onset artifact `1.810 -> 1.840` - - `pitchTest +4` - - still remained pinned to the same shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected - - reverted fully back to the trusted `HS-4 / r6` short-upward blend -- Reconfirmed active baseline after the revert: - - `pitchOrg +4` - - note mel `6.209` - - env `0.961` - - entry mel `6.928` - - onset artifact `1.810` - - output SHA `055B3300041A8B5DE93C462C7E18F180BF7740D0134B0C05B6D255A6A36B46BD` -- Learning: - - short-upward onset strength tables still do not rescue the current scalar-ramp family - - the next meaningful transition-model attempt probably needs a different render structure, not another preset table layered onto the same blend shape - -### Hybrid Structural Iteration HS-12: Short-Upward Exit Handoff -- Hypothesis: - - the remaining miss on the short-upward hybrid path might be more about the note exit than the note entry, so handing back to the legacy renderer earlier and drier near note end could reduce exit damage without touching the frozen `pitchTest +4` fallback -- Change: - - changed the short-upward exit portion of the hybrid blend only: - - start the handoff back to legacy earlier - - cap the exit region to a lower own-engine weight - - leave the onset side and long/downward behavior unchanged -- Result: - - `pitchOrg +4` - - clearly not keepable: - - note mel `6.209 -> 6.214` - - env `0.961 -> 0.973` - - exit mel `6.076 -> 6.350` - - exit env `1.198 -> 1.306` - - onset artifact stayed effectively flat at `1.81` - - `pitchTest +4` - - remained pinned to the same shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected - - reverted back to the trusted `HS-4 / r6` short-upward baseline -- Learning: - - the current short-upward family is not mainly being held back by an exit-only taper problem - - onset, exit, and body behavior are still coupled tightly enough that taper-only changes keep degrading the note before they help the splice - -### Hybrid Structural Iteration HS-13: Plateau-Style Core Takeover -- Hypothesis: - - the current `r6` family may be plateaued because it still behaves like a long scalar ramp, so a structurally different short-upward model with a dry shoulder, fast equal-power entry, steady plateau, and explicit fade-out might break through that ceiling -- Change: - - replaced the short-upward scalar-style blend with a plateau takeover: - - dry shoulder - - fixed equal-power fade into full own-engine plateau - - fixed equal-power fade back out near note end - - left long-note and downward behavior untouched -- Result: - - `pitchOrg +4` - - clearly worse than the trusted `r6` baseline: - - note mel `6.209 -> 6.228` - - env `0.961 -> 0.977` - - entry mel `6.928 -> 7.498` - - exit mel `6.076 -> 6.312` - - onset artifact `1.807 -> 1.840` - - `pitchTest +4` - - stayed pinned to the same shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected - - reverted back to the trusted `HS-4 / r6` baseline -- Learning: - - even a structurally different plateau takeover still made the short-upward note worse - - the next step is no longer another transition law; it needs a different short-upward render structure than legacy/own scalar blending entirely - -### Structural Recovery Cycle: Attack-Preserve Body Replacement -- Goal: - - stop iterating on whole-note legacy/own blend masks and test a true short-upward structural replacement: - - dry attack - - stable voiced entry lock - - rendered body - - dry exit - - keep `pitchOrg +4` and `pitchTest +4` as equal-weight primary targets - - hard-stop any structural path after `2` bounded iterations if it still does not produce a keepable win -- Infrastructure kept: - - added persistent body-replacement diagnostics to the native regression result and saved summaries: - - `bodyReplacementUsed` - - `bodyReplacementFallbackUsed` - - `entryLockStartSec` - - `entryLockLengthMs` - - `exitLockStartSec` - - `renderedBodyStartSec` - - `renderedBodyEndSec` - -#### Path A, Iteration 1: Dry Attack + Existing Own-Engine Body -- Change: - - replaced the short-upward note body with the current own-engine body only between a deterministic voiced entry lock and exit lock - - preserved dry attack and dry exit outside that span -- Result: - - `pitchOrg +4` - - onset artifact improved: - - `1.810 -> 1.603` - - but the note became materially worse overall: - - note mel `6.209 -> 6.282` - - env `0.961 -> 1.036` - - entry mel `6.928 -> 7.759` - - exit mel `6.076 -> 6.517` - - whole-note cents `0.00 -> -18.32` - - `pitchTest +4` - - stayed flat on the same shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Verdict: - - rejected after `1` iteration - - no equal-weight win, and the non-changing `pitchTest +4` meant Path A did not justify a second try -- Learning: - - removing attack-region blending alone is not enough if the rendered body entering after the lock is still structurally mismatched - -#### Path B, Iteration 1: Dry Attack + Epoch-Anchored Body -- Change: - - kept the same entry/exit locks - - replaced the inserted body with an epoch-anchored overlap-add body synthesized from the stable voiced region -- Result: - - `pitchOrg +4` - - large body improvements: - - note mel `6.209 -> 5.418` - - env `0.961 -> 0.616` - - entry mel `6.928 -> 5.250` - - exit mel `6.076 -> 5.636` - - formant drift `0.400 -> 0.126` - - but onset collapsed badly: - - onset artifact `1.810 -> 4.360` - - whole-note cents `0.00 -> +37.23` - - `pitchTest +4` - - still stayed flat on the same shipping-control SHA -- Verdict: - - promising but not keepable - - advanced to a second and final bounded iteration - -#### Path B, Iteration 2: Later, Longer Entry Lock -- Change: - - started the epoch body later and lengthened the entry crossfade to keep more of the original attack dry -- Result: - - `pitchOrg +4` - - onset improved slightly versus Path B iteration 1: - - onset artifact `4.360 -> 4.040` - - but it was still far worse than the kept `r6` baseline - - entry also regressed again: - - entry mel `5.250 -> 7.219` - - `pitchTest +4` - - still stayed flat on the same shipping-control SHA -- Verdict: - - rejected - - Path B exhausted its `2`-iteration budget and did not produce a keepable equal-weight win -- Learning: - - an epoch-anchored body can improve note-core realism, but on this path the onset splice became much worse than the baseline - - the body renderer and the onset handoff are still too tightly coupled here - -#### Path C, Iteration 1: Dry Attack + Harmonic Body -- Change: - - attempted a coherent harmonic-envelope body replacement instead of grain copying -- Result: - - the harmonic body never engaged on `pitchOrg +4` - - render failed closed back to the `r6` baseline: - - note mel `6.209` - - env `0.961` - - onset artifact `1.810` - - output SHA `055B3300041A8B5DE93C462C7E18F180BF7740D0134B0C05B6D255A6A36B46BD` -- Verdict: - - inconclusive first try - - moved to a second and final iteration so the harmonic path could engage deterministically - -#### Path C, Iteration 2: Static Spectral-Envelope Harmonic Body -- Change: - - added a static spectral-envelope fallback so the harmonic body would still render when frame-level harmonic tracks were too sparse -- Result: - - `pitchOrg +4` - - catastrophic failure: - - note mel `6.209 -> 19.738` - - env `0.961 -> 4.495` - - whole-note cents `0.00 -> -762.28` - - entry mel `6.928 -> 25.128` - - exit mel `6.076 -> 28.915` - - harmonic drift `0.400 -> 1.818` - - `pitchTest +4` - - still stayed flat on the same shipping-control SHA -- Verdict: - - rejected immediately - - Path C exhausted its `2`-iteration budget -- Learning: - - the current harmonic-only fallback is not viable as a body renderer in this product path - -#### Baseline Restore After Structural Cycle -- Action: - - restored the experimental hybrid branch to the last trusted `HS-4 / r6` behavior while keeping the new body-replacement diagnostics infrastructure -- Reconfirmed baseline: - - `pitchOrg +4` - - note mel `6.209` - - env `0.961` - - entry mel `6.928` - - exit mel `6.076` - - onset artifact `1.810` - - output SHA `055B3300041A8B5DE93C462C7E18F180BF7740D0134B0C05B6D255A6A36B46BD` - - `pitchTest +4` - - output SHA `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -- Structural-cycle conclusion: - - Path A: rejected after `1/2` - - Path B: rejected after `2/2` - - Path C: rejected after `2/2` - - no structural path beat the trusted `r6` baseline on both equal-weight targets - - the renderer is back on the safe baseline, and the repo now contains full diagnostics for why each body-replacement path failed - - the next step is no longer “another transition law”; it needs a different short-upward render structure than legacy/own scalar blending entirely -### 2026-04-14: Path D, Voiced-Entry-Locked PSOLA Body - -#### Path D, Iteration 1: Fixed-Threshold PSOLA Body -- Change: - - added a short-upward-only voiced-entry-locked TD-PSOLA body path inside the hybrid renderer - - kept long upward and all downward notes on the existing fallback path - - required stable voiced entry, stable voiced exit, at least `5` usable source epochs, and equal-power dry-attack/body/dry-exit splices -- Result: - - `pitchOrg +4` - - strong body improvement versus the `r6` baseline: - - note mel `6.209 -> 5.147` - - env `0.961 -> 0.488` - - entry mel `6.928 -> 5.607` - - exit mel `6.076 -> 5.482` - - harmonic drift `0.400 -> 0.146` - - but onset collapsed badly: - - onset artifact `1.810 -> 4.400` - - onset derivative `+1.81 -> +2.89` - - onset flux `+1.48 -> +4.40` - - `pitchTest +4` - - stayed pinned to the shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` - - body replacement did not engage on this clip -- Verdict: - - promising but not keepable - - advanced to a second and final bounded iteration because the note-body metrics improved materially while the guard case stayed flat - -#### Path D, Iteration 2: Earlier Lock, Longer Entry Fade, Drier Tail -- Change: - - tuned only the allowed PSOLA parameters: - - voiced threshold `0.65 -> 0.60` - - entry sustain `12 ms -> 10 ms` - - entry fade `10 ms -> 12 ms` - - dry exit tail `20 ms -> 24 ms` -- Result: - - `pitchOrg +4` - - body stayed clearly improved versus baseline: - - note mel `6.209 -> 5.105` - - env `0.961 -> 0.474` - - exit mel `6.076 -> 5.383` - - harmonic drift `0.400 -> 0.127` - - onset improved slightly versus Path D iteration 1, but was still much worse than `r6`: - - onset artifact `4.400 -> 3.620` - - entry mel `5.607 -> 6.093` - - still far above baseline onset artifact `1.810` - - `pitchTest +4` - - still stayed pinned to the same shipping-control SHA - - body replacement still did not engage -- Verdict: - - rejected - - Path D exhausted its `2`-iteration budget without beating the equal-weight gate -- Learning: - - voiced-entry-locked PSOLA can produce the best note-body match seen so far on `pitchOrg +4` - - but in this architecture the onset handoff is still too destructive, and the path does not generalize to `pitchTest +4` - -#### Baseline Restore After Path D -- Action: - - restored the active renderer to the trusted `HS-4 / r6` baseline by disabling the PSOLA body path while keeping the diagnostics scaffolding in place -- Reconfirmed baseline: - - `pitchOrg +4` - - note mel `6.209` - - env `0.961` - - entry mel `6.928` - - exit mel `6.076` - - onset artifact `1.810` - - output SHA `055B3300041A8B5DE93C462C7E18F180BF7740D0134B0C05B6D255A6A36B46BD` - - body replacement used/fallback `false / true` - - `pitchTest +4` - - note mel `3.481` - - env `0.623` - - entry mel `2.484` - - exit mel `7.662` - - onset artifact `3.819` - - output SHA `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` - - body replacement used/fallback `false / false` -- Path D conclusion: - - exhausted after `2/2` - - improved note-body fidelity on `pitchOrg +4` more than the prior structural paths - - still failed the gate because onset quality remained substantially worse than the trusted baseline -### 2026-04-14: Path F, Island-Native Renderer With Transient-Preserve Core - -#### Path F, Iteration 1: Fixed-Mask Island-Native Render -- Change: - - added a new experimental branch `pitch_only_island_native` - - rendered short upward note islands as one internal object instead of doing an internal body handoff - - used transient-preserve onset and exit bands from the original signal plus an own-engine voiced-core render, with only outer-island splices - - added island-native diagnostics to the native regression result and saved summaries -- Result: - - `pitchOrg +4` - - the path engaged successfully: - - island native used/fallback `true / false` - - island render span `0.08 -> 0.8300 s` - - transient/core mask peak `1.000 / 0.996` - - onset improved materially versus the `r6` baseline: - - onset artifact `1.810 -> 1.388` - - but note and boundary quality regressed too much: - - note mel `6.209 -> 7.746` - - env `0.961 -> 1.479` - - entry mel `6.928 -> 10.191` - - exit mel `6.076 -> 8.275` - - `pitchTest +4` - - stayed pinned to the shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` - - island-native path did not engage on this clip: - - island native used/fallback `false / false` -- Verdict: - - promising enough for one final bounded tuning pass - - advanced to Iteration 2 because onset improved substantially on the active target while the equal-weight guard stayed flat - -#### Path F, Iteration 2: Lower Voiced-Core Threshold -- Change: - - applied the plan's only allowed F2 adjustment for the observed failure mode: - - lowered voiced-core threshold `0.65 -> 0.60` - - left onset hold, exit hold, and outer splice lengths unchanged -- Result: - - `pitchOrg +4` - - island-native stayed engaged: - - island native used/fallback `true / false` - - island render span `0.08 -> 0.8300 s` - - transient/core mask peak `1.000 / 0.996` - - improved a little versus Path F iteration 1, but still clearly lost to baseline: - - note mel `7.746 -> 7.511` - - env `1.479 -> 1.420` - - entry mel `10.191 -> 9.818` - - exit mel `8.275 -> 7.915` - - onset artifact stayed improved versus baseline: - - `1.810 -> 1.388` - - still failed the equal-weight gate because body and boundary regressions remained far beyond tolerance - - `pitchTest +4` - - still pinned to the same shipping-control SHA - - island-native path still did not engage -- Verdict: - - rejected - - Path F exhausted its `2`-iteration budget without beating the trusted `r6` baseline -- Learning: - - eliminating the internal renderer handoff can improve the onset metric on `pitchOrg +4` - - but this fixed-mask island-native model still damages note body and entry/exit too much - - the path also does not generalize to `pitchTest +4`, because it never activates there under the current gating - -#### Path F Conclusion -- Status: - - exhausted after `2/2` - - not promoted - - shipping fallback and active trusted experimental baseline remain unchanged -- Current trusted live baseline remains: - - `pitchOrg +4` - - note mel `6.209` - - env `0.961` - - entry mel `6.928` - - exit mel `6.076` - - onset artifact `1.810` - - output SHA `055B3300041A8B5DE93C462C7E18F180BF7740D0134B0C05B6D255A6A36B46BD` - - `pitchTest +4` - - note mel `3.481` - - env `0.623` - - entry mel `2.484` - - exit mel `7.662` - - onset artifact `3.819` - - output SHA `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` -### 2026-04-14: Path G, Adaptive Island-Native PSOLA-Core - -#### Path G, Iteration 1: Fixed-Threshold Island Shell + PSOLA Core -- Change: - - added a new experimental branch `pitch_only_island_native_psola` - - kept the Path F island-native shell and outer-only splice policy - - replaced the island voiced-core source with a PSOLA core synthesized only from stable voiced epochs inside the island - - added one relaxed engagement retry for `pitchTest`-type clips: - - voiced threshold `0.55` - - sustain `8 ms` - - left long upward and all downward notes on fallback behavior -- Result: - - `pitchOrg +4` - - branch engaged successfully: - - island native used/fallback `true / false` - - island render span `0.08 -> 0.8300 s` - - transient/core mask peak `1.000 / 0.996` - - onset stayed improved versus the trusted `r6` baseline: - - onset artifact `1.810 -> 1.388` - - but note and boundary quality regressed even harder than the fixed-mask own-engine-core island path: - - note mel `6.209 -> 8.053` - - env `0.961 -> 1.608` - - entry mel `6.928 -> 11.972` - - exit mel `6.076 -> 16.651` - - note cents `0.00 -> +18.52` - - `pitchTest +4` - - stayed pinned to the same shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` - - the relaxed engagement retry still did not activate the path: - - island native used/fallback `false / false` -- Verdict: - - rejected after `1/2` - - the path failed the equal-weight gate immediately, so no G2 tuning pass was allowed -- Learning: - - combining the Path F shell with a PSOLA core preserved the onset-side gain on `pitchOrg +4` - - but the PSOLA core destabilized pitch/body/exit inside the island - - the path still does not generalize to `pitchTest +4`, even with the relaxed engagement retry - -#### Path G Conclusion -- Status: - - stopped after `1/2` under the stop-fast rule - - not promoted - - shipping fallback and trusted `HS-4 / r6` experimental baseline remain unchanged -### 2026-04-15: Family `FAM-V2-HSR`, Engine V2 Harmonic/Source-Filter + Residual Shell - -#### Iteration 1: `pitch_only_engine_v2` -- Change: - - added a temporary `pitch_only_engine_v2` branch - - reused the island-native shell for short upward notes only - - replaced the fixed own-engine core with an explicit v2 composition: - - original transient-preserve shell - - own-engine harmonic/source-filter core - - explicit residual layer from the island residual model - - kept long upward and all downward behavior on fallback -- Result: - - `pitchOrg +4` - - onset stayed improved versus `r6`: - - onset artifact `1.810 -> 1.388` - - but the note and boundaries regressed clearly: - - note mel `6.209 -> 7.975` - - env `0.961 -> 1.454` - - entry mel `6.928 -> 9.737` - - exit mel `6.076 -> 12.703` - - note cents `0.00 -> -18.32` - - `pitchTest +4` - - stayed pinned to the same shipping-control SHA: - - `1A9B12D8EA57EFEAC7B3505735BC9D4DA5F666D98703DA1333549314719CC043` - - the branch still did not engage on this clip -- Verdict: - - rejected after `1/2` - - it failed the equal-weight gate decisively, so it did not earn a second iteration -- Cleanup: - - removed the temporary `pitch_only_engine_v2` branch code immediately after the reject -- Learning: - - adding an explicit residual layer to the island-native shell did not solve the onset/body tradeoff - - the family preserved the onset-side gain seen in prior island-native attempts - - but it still could not preserve note body or generalize to `pitchTest +4` - -### 2026-04-16: Stopped-Transport Scrub Preview Fix + `engine-v2` Tuning R7 - -#### Scrub Preview Audibility -- Change: - - moved the RAM scrub loop out of the transport-gated clip playback path - - added a dedicated preview voice rendered directly from the main audio callback - - added start/stop ramps and native status counters for scrub regression - - added a dedicated scrub regression runner: - - `tools/run-ui-pitch-scrub-regression.ps1` -- Result: - - run: `20260416_153028_pitchOrg_scrub_preview_r2` - - stopped-transport scrub preview is now measurably active: - - `scrubPreviewAudible=true` - - start latency `27.9 ms` - - stop latency `27.5 ms` - - mixed callback count `3` - - mixed sample count `3072` - - last peak `0.0005417` -- Verdict: - - structural scrub-audibility blocker fixed - - still needs user audition in the real editor path, but the old “silent while stopped” wiring gap is no longer hidden - -#### Render Tuning R7 -- Change: - - kept `pitch_only_engine_v2_program` active - - made the cepstral lifter F0-adaptive - - raised overlap inside the envelope-restore stage - - tightened transient ownership and shifted entry focus earlier - - widened the transition lead/tail slightly while keeping the adaptive carrier underneath - - added boundary timing metrics to the comparison script: - - `entryLagMs` - - `exitLagMs` - - `onsetDelayMs` - - `boundaryTimingErrorMs` -- Result: - - `pitchOrg +4` - - run: `20260416_152736_pitchOrg_plus4_note_hq_engine_v2_tune_r7` - - note mel `7.314` - - env `1.347` - - entry mel `7.396` - - exit mel `7.027` - - onset artifact `1.388` - - formant body harmonic drift `0.415` - - `pitchTestOrg +4` - - run: `20260416_152736_pitchTestOrg_plus4_note_hq_engine_v2_tune_r7` - - note mel `3.540` - - env `0.521` - - entry mel `7.513` - - exit mel `1.632` - - onset artifact `4.013` - - onset delay `1.479 ms` - - formant body harmonic drift `0.069` - - fresh adaptive controls: - - `pitchOrg +4`: `20260416_153028_pitchOrg_plus4_note_hq_adaptive_cmp_r3` - - note mel `7.085` - - entry mel `7.078` - - onset artifact `1.803` - - harmonic drift `0.369` - - `pitchTestOrg +4`: `20260416_153028_pitchTestOrg_plus4_note_hq_adaptive_cmp_r3` - - note mel `2.810` - - entry mel `1.530` - - onset artifact `0.705` - - harmonic drift `0.066` -- Verdict: - - the tuned `engine-v2` path is now safer on the easy clip than the earlier catastrophic versions - - but it still loses clearly to `pitch_only_adaptive_selector` on the hard note-entry case - - the current repo truth remains: - - adaptive is still the audible winner - - scrub preview audibility is structurally fixed - - boundary/formant tuning still needs more work - -### 2026-04-16: Harness Close-Out Progress, Scrub Suite + Transient/Formant Smoke Suites - -#### Multi-scenario scrub suite -- Change: - - extended the scrub regression job to accept: - - repeated drag cycles - - optional transport play/stop cycle before scrub - - added `tools/run-ui-pitch-scrub-suite.ps1` - - isolated suite outputs into their own `case_runs` directory to avoid cross-run collisions -- Result: - - run: `20260416_205833_pitchOrg_scrub_suite_smoke_r3` - - case summary: - - `first_drag` - - audible `true` - - first-drag audible `true` - - start / stop latency `26.8 / 26.7 ms` - - `repeated_drag` - - audible `true` - - first-drag audible `true` - - start / stop latency `27.0 / 26.3 ms` - - scenario count recorded as `3` - - `after_transport_cycle` - - audible `true` - - first-drag audible `true` - - start / stop latency `27.5 / 26.4 ms` - - scenario count recorded as `2` -- Verdict: - - scrub harness coverage is now broader than the old single happy-path run - - but `H1` is still only partial because: - - selection-change scrub is not yet covered - - per-scenario pass breakdown is not fully propagated into the result JSON - - the audible “breaking loop” complaint is still a listening issue, not a solved benchmark issue - -#### Transient and formant suite harnesses -- Change: - - added a manifest-driven suite runner: - - `tools/run-ui-pitch-regression-suite.ps1` - - added wrappers: - - `tools/run-ui-pitch-transient-suite.ps1` - - `tools/run-ui-pitch-formant-suite.ps1` - - added smoke manifests: - - `tests/fixtures/pitch-regression/suites/transient_smoke_suite.json` - - `tests/fixtures/pitch-regression/suites/formant_smoke_suite.json` - - fixed suite output collisions by isolating per-suite `case_runs` -- Result: - - transient smoke run: `20260416_205335_transient_suite_smoke_r3` - - `pitchOrg +4` - - note mel `6.671` - - onset artifact `1.825` - - entry / exit artifact `6.918 / 7.138` - - onset delay `-0.104 ms` - - boundary timing error `8.417 ms` - - formant drift `0.374` - - `pitchTestOrg +4` - - note mel `2.802` - - onset artifact `2.051` - - entry / exit artifact `1.489 / 1.627` - - onset delay `-0.625 ms` - - boundary timing error `0.958 ms` - - formant drift `0.050` - - formant smoke run: `20260416_205335_formant_suite_smoke_r3` - - same canonical smoke cases now flow through a dedicated formant-focused suite wrapper and summary -- Verdict: - - `H3` and `H4` harness plumbing now exist and run cleanly - - but they are still only partial because the repo does not yet contain the full transient/formant fixture set from the plan - -#### Remaining plan truth -- The full recovery plan is still not complete. -- Remaining mandatory iterations: `14` -- Remaining conditional iterations: `+2` - -### 2026-04-16: Adaptive Carrier Boundary/Formant Correction Pass R2 - -#### Adaptive selector correction-layer pass -- Change: - - kept `pitch_only_adaptive_selector` as the carrier - - added a narrow adaptive boundary-correction layer in `PitchResynthesizer.cpp` - - applied transient/unvoiced bypass, cepstral envelope restore, and residual carry only inside note entry/exit windows - - exposed correction diagnostics through the existing render summary so the run proves whether the path actually engaged -- Result: - - suite run: `20260416_210838_adaptive_boundary_tune_r2` - - `pitchOrg +4` - - note mel `6.548` - - env `1.044` - - entry mel `7.383` - - exit mel `7.007` - - onset artifact `3.259` - - boundary timing error `8.417 ms` - - formant drift `0.372` - - `spectralEnvelopeCorrectionUsed=true` - - `pitchTestOrg +4` - - note mel `3.029` - - env `0.511` - - entry mel `1.993` - - exit mel `5.269` - - onset artifact `0.824` - - boundary timing error `5.458 ms` - - formant drift `0.038` - - `spectralEnvelopeCorrectionUsed=true` - - comparison against the prior adaptive smoke baseline: - - `pitchOrg +4` - - slightly better note mel/env - - much worse onset artifact - - entry slightly worse - - exit slightly better - - `pitchTestOrg +4` - - onset artifact improved sharply - - formant proxy improved slightly - - note mel/env regressed - - exit and boundary timing regressed badly -- Verdict: - - this pass is real and engaged; it is not another stale-binary false read - - it is still not keepable in its current shape - - the failure pattern says the next adaptive-carrier work should focus on: - - `A3` transient handoff crossfade sweep - - `A4` entry timing compensation - - `A5` F0-adaptive cepstral lifter retune - -#### Remaining plan truth -- The full recovery plan is still not complete. -- Remaining mandatory iterations: `13` -- Remaining conditional iterations: `+2` - -### 2026-04-16: Adaptive Carrier Boundary/Formant Correction Pass R3 - -#### Adaptive selector correction-layer retune -- Change: - - kept the adaptive carrier correction path active - - split entry vs exit defaults so note exit ownership is much lighter than note entry - - widened the outer correction crossfade, pushed entry focus slightly earlier, and reduced correction wetness near the raw boundary -- Result: - - suite run: `20260416_211448_adaptive_boundary_tune_r3` - - `pitchOrg +4` - - note mel `6.537` - - env `1.040` - - entry mel `7.435` - - exit mel `7.028` - - onset artifact `2.383` - - boundary timing error `8.396 ms` - - formant drift `0.373` - - `spectralEnvelopeCorrectionUsed=true` - - `pitchTestOrg +4` - - note mel `2.960` - - env `0.493` - - entry mel `1.876` - - exit mel `4.067` - - onset artifact `1.540` - - boundary timing error `2.250 ms` - - formant drift `0.039` - - `spectralEnvelopeCorrectionUsed=true` - - comparison against `r2`: - - `pitchOrg +4` - - note mel/env improved slightly - - onset artifact improved sharply - - entry stayed a little worse than the pre-correction baseline - - `pitchTestOrg +4` - - note mel/env/entry/exit all improved versus `r2` - - boundary timing also improved strongly - - but exit and boundary timing still remain materially worse than the original adaptive baseline -- Verdict: - - `r3` is a real improvement over `r2` - - it is still not the fix: - - the easy clip still has too much onset damage - - the hard clip still has too much exit damage - - the next honest priorities remain: - - `A3` transient handoff crossfade sweep - - `A4` entry timing compensation - - `A5` cepstral lifter retune - -#### Remaining plan truth -- The full recovery plan is still not complete. -- Remaining mandatory iterations: `12` -- Remaining conditional iterations: `+2` - -### 2026-04-16: Adaptive Carrier Boundary/Formant Correction Pass R5 - -#### Adaptive selector compromise profile -- Change: - - kept the adaptive carrier correction path active - - tuned toward a compromise profile after the `r4` onset-safe and exit-safe sweeps: - - lower flatness center and slightly higher RMS gate - - shorter entry crossfade - - earlier entry bias - - lighter entry and exit wetness - - lighter residual carry - - promoted this profile into the default code path because it is the best adaptive correction run so far -- Result: - - suite run: `20260416_212441_adaptive_boundary_tune_r5_compromise` - - `pitchOrg +4` - - note mel `6.533` - - env `1.039` - - entry mel `7.462` - - exit mel `7.044` - - onset artifact `2.261` - - boundary timing error `8.396 ms` - - formant drift `0.373` - - `spectralEnvelopeCorrectionUsed=true` - - `pitchTestOrg +4` - - note mel `2.935` - - env `0.487` - - entry mel `1.833` - - exit mel `3.665` - - onset artifact `1.748` - - boundary timing error `2.250 ms` - - formant drift `0.040` - - `spectralEnvelopeCorrectionUsed=true` - - comparison against earlier adaptive correction passes: - - this is the best mixed result so far - - it improves `pitchTestOrg +4` note/entry/exit/formant metrics versus `r2` and `r3` - - it also improves `pitchOrg +4` onset artifact versus `r2` and `r3` - - but it still does not beat the plain adaptive baseline on the note-start/note-end problems that matter most -- Verdict: - - the adaptive correction layer is now a real tunable path, not just a failed idea - - but it is still not the proper fix yet - - remaining priority order should be: - - `A4` entry timing compensation - - `A5` cepstral lifter retune - - then either: - - one more `A3` cleanup pass if start roughness still dominates listening, or - - freeze the adaptive correction layer as a non-default experiment if it keeps failing against plain adaptive - -#### Remaining plan truth -- The full recovery plan is still not complete. -- Remaining mandatory iterations: `11` -- Remaining conditional iterations: `+2` - -### 2026-04-16: Adaptive Carrier Timing/Formant Retune R6 + Onset Cleanup R7 - -#### Timing/formant retune -- Change: - - added explicit entry/exit pre/post window tuning to the adaptive correction path - - made cepstral lifter scale and correction strength tunable instead of fixed - - softened the default cepstral profile and reduced correction authority near the raw boundary -- Result: - - run: `20260416_213431_adaptive_boundary_tune_r6_timing_formant` - - `pitchOrg +4` - - note mel `6.541` - - env `1.038` - - entry mel `7.298` - - exit mel `7.112` - - onset artifact `2.261` - - formant drift `0.373` - - `pitchTestOrg +4` - - note mel `2.830` - - env `0.470` - - entry mel `1.755` - - exit mel `1.887` - - onset artifact `1.748` - - formant drift `0.050` -- Verdict: - - this was a real improvement on the hard clip, especially at exit - - formant drift did not materially improve - - the easy clip still kept too much start-side damage - -#### Onset cleanup follow-up -- Change: - - raised the onset RMS gate slightly - - reduced entry wetness again - - shortened the entry pre/post ownership window - - promoted the resulting profile into the default code path because it outperformed the earlier adaptive correction runs overall -- Result: - - run: `20260416_213810_adaptive_boundary_tune_r7_onsetcleanup` - - `pitchOrg +4` - - note mel `6.526` - - env `1.023` - - entry mel `6.890` - - exit mel `7.112` - - onset artifact `2.261` - - formant drift `0.374` - - `pitchTestOrg +4` - - note mel `2.828` - - env `0.470` - - entry mel `1.707` - - exit mel `1.887` - - onset artifact `1.714` - - formant drift `0.050` -- Verdict: - - `r7` is the strongest adaptive correction profile so far - - it narrows the hard-case gap substantially - - but it still does not solve the two main user-facing issues: - - `pitchOrg +4` note start is still too artifacted versus plain adaptive - - `pitchTestOrg +4` note exit and onset are still not as clean as the plain adaptive baseline - -#### Remaining plan truth -- The full recovery plan is still not complete. -- Remaining mandatory iterations: `10` -- Remaining conditional iterations: `+2` - -### 2026-04-16: Adaptive Carrier Formant Sweep R8 + Residual Sweep R9 - -#### Cepstral/formant sweep -- Change: - - ran two formant-focused profiles on the adaptive correction path: - - `r8 strong`: lower lifter scale and stronger cepstral correction - - `r8 soft`: higher lifter scale and softer cepstral correction -- Result: - - runs: - - `20260416_223105_adaptive_formant_tune_r8_strong` - - `20260416_223105_adaptive_formant_tune_r8_soft` - - both profiles were effectively flat on the smoke suite - - differences were tiny enough that they do not justify a default change yet -- Verdict: - - `A5` is not the dominant lever on the current smoke cases - - richer sustained-vowel fixtures are still needed before declaring cepstral tuning closed in a product sense - -#### Residual carry sweep -- Change: - - compared a dry residual profile against a wetter residual profile on the same formant smoke suite -- Result: - - runs: - - `20260416_223535_adaptive_residual_tune_r9_dry` - - `20260416_223535_adaptive_residual_tune_r9_wet` - - `r9 dry` won slightly but consistently: - - `pitchOrg +4` - - note mel `6.525` - - env `1.022` - - entry mel `6.885` - - onset artifact `2.257` - - `pitchTestOrg +4` - - note mel `2.828` - - env `0.469` - - entry mel `1.703` - - onset artifact `1.688` - - the wetter residual profile was slightly worse on both smoke cases -- Verdict: - - `A6` currently favors keeping residual reinjection off in the adaptive correction layer - - the default code path now uses the dry residual profile - -#### Remaining plan truth -- The full recovery plan is still not complete. -- Remaining mandatory iterations: `8` -- Remaining conditional iterations: `+2` - -### 2026-04-16: Adaptive Carrier STFT Sweep R10 - -#### STFT correction-layer sweep -- Change: - - exposed the cepstral correction stage FFT order and hop divisor as explicit tuning controls - - compared: - - `1024 / 8` - - `2048 / 8` - - `2048 / 4` -- Result: - - runs: - - `20260416_224905_adaptive_stft_tune_r10_1024o8` - - `20260416_224905_adaptive_stft_tune_r10_2048o8` - - `20260416_224905_adaptive_stft_tune_r10_2048o4` - - all three profiles were effectively identical on both smoke cases - - representative values: - - `pitchOrg +4` - - note mel `6.525` - - env `1.022` - - entry `6.885` - - exit `7.112` - - onset artifact `2.257` - - formant drift `0.374` - - `pitchTestOrg +4` - - note mel `2.828` - - env `0.469` - - entry `1.703` - - exit `1.887` - - onset artifact `1.688` - - formant drift `0.050` -- Verdict: - - `A7` is effectively flat on the current adaptive correction path - - FFT size / hop is not the dominant remaining lever here - - remaining budget should move to: - - richer fixture completion for `H1/H3/H4` - - challenger close-out `B1-B4` - -#### Remaining plan truth -- The full recovery plan is still not complete. -- Remaining mandatory iterations: `7` -- Remaining conditional iterations: `+2` - -### 2026-04-16: Engine-v2 Challenger Narrow Close-Out R8 - -#### Narrowed/drier engine-v2 pass -- Change: - - narrowed engine-v2 ownership further around the edited transition nucleus - - made its transient flatness gates, entry bias, core wetness, residual wetness, and cepstral tuning explicit - - dried the path down substantially and shortened its transition window so it behaved more like a challenger overlay than a broad note takeover -- Result: - - runs: - - `20260416_230357_enginev2_narrow_pitchOrg_plus4_r8` - - `20260416_230555_enginev2_narrow_pitchTest_plus4_r8` - - `pitchOrg +4` - - note mel `6.608` - - env `1.009` - - entry mel `7.620` - - exit mel `7.112` - - onset artifact `1.390` - - formant drift `0.395` - - `pitchTestOrg +4` - - note mel `3.143` - - env `0.498` - - entry mel `7.017` - - exit mel `1.887` - - onset artifact `4.000` - - formant drift `0.062` -- Verdict: - - this is enough to close the challenger honestly - - the narrowed engine-v2 pass still loses clearly on the hard clip - - it is worth keeping in the repo for reference/audition, but not for more main-budget tuning - - the adaptive carrier remains the only live path worth carrying forward - -#### Remaining plan truth -- The full recovery plan is still not complete. -- Remaining mandatory iterations: `3` -- Remaining conditional iterations: `+2` - -### 2026-04-27: Direction-Specific Note-HQ Timbre + Exit Polish - -#### Renderer decision -- Change: - - moved production `note_hq` pitch-only rendering to native direction-specific HQ. - - upward edits keep the current native adaptive branch. - - downward edits now use a guarded formant law instead of sharing the upward compensation path. - - Rubber Band remains installed/diagnosed and can be forced for benchmarks with `OPENSTUDIO_PITCH_USE_RUBBERBAND_HQ=1`, but it is not quality-promoted for production note-HQ. -- Reason: - - the previous native bug was signal-chain correctness, not a renderer-family opening: detected F0 guidance had been passed as `formantRatios` in pitch-only calls. - - the remaining audible downshift issue was the native compensation law, not a reason to require Rubber Band. - - the measured Rubber Band benchmark still failed the `+4` mid-band formant gate and had worse boundary timing than native. - -#### Downshift formant guard -- Change: - - pitch-only downward edits use `pow(1 / ratio, alpha)` with default alpha `0.58`. - - downshift envelope anchoring was strengthened around the voiced `200-3500 Hz` body. - - native result diagnostics now report direction, selected branch, downshift guard usage, guard alpha, effective note-HQ range, and Rubber Band quality-promotion status. -- Result: - - primary `pitchOrg -4` run: `tmp_pitch_runs/20260427_160222_direction_guard_v3_pitchOrg_minus4`. - - harmonic-envelope drift improved from the prior native `0.469` to `0.353`. - - body low/mid/high deltas were `-0.420 / -1.280 / +0.208 dB`. - - body/core cents were `0.00 / +11.90`. - - onset artifact was `1.40`; exit-next artifact was `1.927`. -- Verdict: - - the downshift timbre problem is materially improved and now passes the practical gate. - - the aspirational harmonic-drift target remains `<= 0.35`; this pass landed just above it at `0.353`, so future work should treat that as polish, not an emergency renderer swap. - -#### Exit-to-next-note polish -- Change: - - note-HQ transition ownership is asymmetric by default: shorter pre-note shoulder, longer post-note shoulder, and a small next-note head when adjacent notes touch. - - final native compositing uses a wider dry-protected crossfade at the effective commit range. - - the harness now reports exit-to-next-note discontinuity metrics and writes `cand_exit_next.wav`. -- Result: - - `pitchOrg +4`: `tmp_pitch_runs/20260427_160222_direction_guard_v3_pitchOrg_plus4`, exit-next artifact `1.110`. - - `pitchOrg -4`: `tmp_pitch_runs/20260427_160222_direction_guard_v3_pitchOrg_minus4`, exit-next artifact `1.927`. - - boundary suite passed: `tmp_pitch_runs/20260427_162913_direction_guard_v4_boundary_plus4`. -- Verdict: - - the remaining word-break issue is now handled as an edited-note exit handoff metric rather than being hidden inside broad note/window scores. - -#### Validation -- Passed: - - primary `pitchOrg +4` and `pitchOrg -4` note-HQ acceptance runs. - - export parity for both directions: - - `tmp_pitch_runs/20260427_162615_direction_guard_v4_export_parity_plus4` - - `tmp_pitch_runs/20260427_162615_direction_guard_v4_export_parity_minus4` - - richer formant suite: - - `tmp_pitch_runs/20260427_161118_direction_guard_v4_formant_richer` - - richer transient suite: - - `tmp_pitch_runs/20260427_161118_direction_guard_v4_transient_richer` - - boundary suite: - - `tmp_pitch_runs/20260427_162913_direction_guard_v4_boundary_plus4` -- Harness correction: - - phrase/full-clip note-HQ candidates are scored as full-clip audio. - - window-local semantics remain only for actual segment renders such as preview segments. - - downshift F1/F2 hard gates use the more stable note-body proxy on the canonical window; core F2 remains reported but is too unstable to gate alone on this fixture. - -### 2026-04-27: Two-Sided Note-HQ Boundary Follow-Up - -#### Problem -- User feedback: - - the edited-note exit/next-note handoff was improved. - - a stutter/word-break became audible on the note or word before the edited note. -- Root cause: - - note-HQ rendering had enough left context, but the dry-protected compositor used a generic fixed fade. - - with a left shoulder, that let wet audio become fully committed before the edited note body actually began. - -#### Change -- Commit ranges now retain: - - effective shoulder start/end. - - true note body start/end. -- Entry behavior: - - dry-to-wet fade spans the whole left shoulder. - - full wet is reached at the edited note body start, not before it. -- Exit behavior: - - wet-to-dry release starts with a small `12 ms` lead-in before the note body end, then fades through the right shoulder. - - this lowers the derivative discontinuity at the edited-note exit without returning to the previous-word artifact. -- Harness: - - added `preCommitArtifactScore`. - - added `cand_pre_commit.wav` audition excerpt. - - dry-neighbor residual checks now evaluate only the unowned dry neighbor region outside the effective note-HQ commit range. - -#### Result -- `pitchOrg +4`: - - run: `tmp_pitch_runs/20260427_202041_direction_entry_guard_v6_plus4` - - pre-commit/onset artifact: `0.055 / 1.791` - - exit-next artifact: `2.307` - - body/core cents: `0.00 / 0.00` - - harmonic drift: `0.378` -- `pitchOrg -4`: - - run: `tmp_pitch_runs/20260427_202204_direction_entry_guard_v6_minus4` - - pre-commit/onset artifact: `0.093 / 1.398` - - exit-next artifact: `0.091` - - body/core cents: `0.00 / +11.90` - - harmonic drift: `0.352` -- Boundary suite: - - run: `tmp_pitch_runs/20260427_boundary_entry_guard_v6b/20260427_202849_direction_entry_guard_v6_boundary` - - summary: `pitch_boundary_suite_summary.md` - - start-edge cases stayed clean on the new pre-commit metric. - -#### Verdict -- The correct ownership model is not a larger shoulder by itself. -- The renderer should have phrase/shoulder context, but the final commit blend must be note-body-aware so the previous word remains dry until the transition actually belongs to the edited note. diff --git a/docs/pitch_recovery_master_map.md b/docs/pitch_recovery_master_map.md deleted file mode 100644 index e3ddb12..0000000 --- a/docs/pitch_recovery_master_map.md +++ /dev/null @@ -1,324 +0,0 @@ -# Pitch Recovery Master Map - -## Current Controls -- 2026-05-02 Vienna VSF audition pivot: - - user audition rejected the adaptive family for the start/pre-start artifact on the Vienna clip; VSF HQ hybrids-disabled was the only tested family that did not have that start artifact. - - `vienna_vsf_residual_less_055_plus4_full` and `vienna_vsf_residual_less_055_minus4_full` are the current best audition baselines, with residual scale `0.55`; the remaining target is +4 edited-note body clashing/distortion, while -4 nasal tone is regression context. - - body/dry blend variants are rejected because they sounded like two voices singing the edited pitch; do not continue with reduced core wet or dry/body blend candidates for this issue. - - current repair direction is VSF epoch-carrier only: optional upward source-epoch interpolation plus upward grain-radius scaling, with diagnostics reporting residual scale, epoch interpolation use, and effective grain scale. - - iterative fine-tuning round 1 is staged in `tmp_pitch_runs\vienna_vsf_iter_20260502_041147`: exact relative `+4.00`, VSF HQ, hybrids disabled, residual scale `0.55`, core wet `1.0`, and candidate WAVs for baseline `grain065`, interpolation strengths `0.75/0.50`, grain radius `0.60/0.70` at interpolation `0.75`, and `-1.5 dB` upward body presence trim variants. All deterministic harness gates passed, but clashing/distortion, doubled voice, formant/timbre, naturalness, and start artifact remain `not_asserted` until user audition. - - user audition currently ranks `vienna_vsf_iter_grain070_interp075_plus4_full` as the best +4 candidate so far; this is best-by-audition, not a fixed/completed claim. - - local diagnostic-only helper `local_tools\pitch\pitch_residual_hotspot_report.py` reported residual hotspots by time slice and broad band against the batch-local `grain065` baseline; these residual/hotspot metrics are not quality claims and the helper is not tracked. - - downshift nasal iteration is staged in `tmp_pitch_runs\vienna_vsf_minus4_iter_20260502_044712`: exact relative `-4.00`, VSF HQ, hybrids disabled, residual scale `0.55`, core wet `1.0`, no grain/epoch tuning, and candidate WAVs for baseline, `-1.5 dB` nasal trims at `900/1100/1300 Hz`, plus `1100 Hz` nasal trim with `+1.0 dB` body compensation at `430 Hz`. All deterministic harness gates passed, and EQ variants are not byte-identical to baseline. User audition currently ranks `vienna_vsf_iter_minus4_nasal1100_full` as the best -4 candidate so far; nasal tone, distortion, doubled voice, formant/timbre, naturalness, and start artifact remain audition-gated and not a fixed/completed claim. - - default VSF tuning now follows the current user-picked paths: upward shifts enable source-epoch interpolation by default with interpolation strength `0.75` and upward grain radius scale `0.70`; downward shifts apply `-1.5 dB` body nasal trim at `1100 Hz` by default. Env overrides remain available for diagnostics. -- 2026-04-29 doubled-core recovery: - - app audition still reported an artificial doubled vocal even after mono spectrogram gates passed, so the current default is treated as audition-not-done until the stable edited-note core clears a dedicated double-voice QA pass. - - historical state at that point: product/default note-HQ pitch-only selection was restored to `pitch_only_adaptive_selector` with `legacy_natural` recovery; this is superseded by the 2026-05-02 Vienna VSF audition pivot above. - - likely cause addressed first: the vocal source/filter output was being blended with the adaptive-selector core on long notes by default. That core hybrid is now opt-in via `OPENSTUDIO_VSF_CORE_HYBRID_ENABLE=1` or the legacy diagnostic override `OPENSTUDIO_VSF_CORE_HYBRID_DISABLE=0`. - - `tools\pitch_double_voice_analyze.py` is now part of reference-backed pitch-only `note_hq` runs and gates original-F0 leakage, secondary pitch excess, stereo correlation drift, mid/side drift, comb/notch excess, and dry-correlation excess. - - mid/side drift uses an audible-side floor, so nearly mono side residue is reported as raw drift without falsely failing the doubled-core gate. - - layer dumps for diagnosis are available via `-DumpPitchLayers` / `OPENSTUDIO_VSF_LAYER_DUMP_ENABLE=1`; they write dry input, source/filter core, residual/noise, wet envelope, adaptive hybrid output when engaged, and final output. - - lesson: doubled-core artifacts are phase/stereo/layering failures, so mono mel and formant proxies cannot be the final naturalness proof. -- 2026-04-29 spectrogram-first QA correction: - - completion claims for reference-backed pitch-only `note_hq` renders now require a final spectrogram/mel/waveform-envelope report for both upshift and downshift; if the spectrogram done gate fails, the work is explicitly "not done" and the next failing region is named. - - the failure baseline is `D:\test projects\os tests\runs\20260429_074056_vocal_source_filter_pitchTest_plus4_transient_smoke`: `cand_phrase.wav` vs `ref_phrase.wav` measured about `12.15 dB` whole-phrase mel MAE, phrase short-RMS envelope correlation `0.177`, phrase high-band delta about `+5.25 dB`, core high-band delta about `+6.23 dB`, entry RMS about `+7.6 dB`, exit RMS about `-5.45 dB`, onset peak jump about `+28.8 dB`, onset high-band burst about `+12.9 dB`, and candidate/reference lag about `54 ms`. - - lesson: pitch cents, branch diagnostics, and proxy formant gates can pass while the rendered vocal is still unusable. Spectrogram/mel/waveform-envelope checks and human audition now veto completion. - - next repair order is entry/gating burst first, residual/noise smear second, voiced-core naturalness third, and timing/ownership audit fourth. Do not widen dry-protected neighbor commits just to match references that changed wider phrase audio. -- 2026-04-29 source/filter artifact repair status: - - current code adds PSOLA overlap-weight normalization, longer dry entry ownership, delayed entry-only gain/EQ shaping, duration-specific long-note RMS trim, and bounded long-note adaptive hybrids for entry/core/exit in the `pitch_only_vocal_source_filter_hq` renderer. - - latest canonical `pitchOrg` reports remain close inside the edited note after the long-note-only hybrid change: `20260429_122136_spectrogram_gate_pitchOrg_plus4_after_exit_hybrid_diag` measured core/entry/exit mel `4.68/7.41/5.71 dB`, and `20260429_122322_spectrogram_gate_pitchOrg_minus4_after_exit_hybrid_diag` measured `4.35/6.14/5.61 dB`. - - latest harder `pitchTestOrg` reports have the note-owned spectrogram regions inside the current gates: `20260429_123450_spectrogram_gate_pitchTest_plus4_core_hybrid_diag` measured core/entry/exit mel `6.84/4.29/6.17 dB`, and `20260429_123742_spectrogram_gate_pitchTest_minus4_core_hybrid_diag` measured `6.47/4.27/7.74 dB`. - - these runs are still not a final completion claim until app audition is clean. The remaining phrase-wide envelope failure is non-actionable on these references because original-vs-reference pre/post-neighbor mismatch is about `15/19 dB`; the harness now reports that mismatch and skips the phrase-envelope done gate in that case. Positive onset bursts remain hard failures, but quieter-than-reference onset deltas are reported as target mismatch rather than burst artifacts. - - next concrete fix if audition still sounds wrong: inspect the long-note `cand_core.wav` and `cand_exit.wav` spectrograms manually against `ref_core.wav`/`ref_exit.wav`, then tune only the core hybrid amount or replace the adaptive core support. Do not widen dry-protected neighbor commits just to improve phrase correlation. -- 2026-04-29 default vocal source/filter hard-gate follow-up: - - short upward entries now use a shorter `24 ms` dry shell and a near-neutral short-up entry mid cut (`OPENSTUDIO_VSF_SHORT_UP_ENTRY_MID_CUT_DB`, default `-3 dB`) to remove the residual onset-flux failure without affecting downshift or long-note policies. - - the four default-branch hard checks now pass pitch-quality and spectrogram gates with `actualRendererBranch=pitch_only_vocal_source_filter_hq`: - - `20260429_132647_spectrogram_gate_pitchOrg_plus4_short_entry_eq3_hard_check`: core/entry/exit mel `4.09/5.37/5.70 dB`. - - `20260429_132935_spectrogram_gate_pitchOrg_minus4_after_short_entry_eq3_hard_check`: `4.35/6.14/5.61 dB`. - - `20260429_132935_spectrogram_gate_pitchTest_plus4_after_short_entry_eq3_hard_check`: `6.84/4.29/6.17 dB`. - - `20260429_132935_spectrogram_gate_pitchTest_minus4_after_short_entry_eq3_hard_check`: `6.47/4.27/7.74 dB`. - - final status remains audition-gated: these metrics say the renderer is no longer the obvious ghost/robot failure from the spectrogram baseline, but app listening still wins over the proxy gates. -- 2026-04-28 Signalsmith pitch-only formant-contract correction: - - pitch-only vocal note edits now call `setFormantFactor(1.0f, true)` in the Signalsmith carrier, matching live preview and the real-time corrector. - - active adaptive-selector pitch-only carriers pass detected F0 through `setFormantBase(...)` when available, and the offline transpose map uses the same stage-A tonality-limit controls as live preview. - - explicit formant edits pass their requested ratio directly with `compensatePitch=true`; pitch ratio is not folded into the formant factor. - - downshift-specific timbre support remains a bounded adaptive-selector blend/envelope-transfer concern (`OPENSTUDIO_PITCH_DOWNSHIFT_OWN_BLEND` defaults to `0.42`), not inverse-ratio carrier compensation. -- 2026-04-28 entry contour-handoff correction: - - the start artifact was reclassified as a pitch-trajectory/ownership mismatch: the renderer needs pre-note ratio context, but final audible ownership must still be controlled by the entry bridge. - - measured tuning showed that hard/unknown `pitchOrg` entries must keep render pre-roll and reach the target by `note.startTime`; adding an audible body ramp there regressed entry lag and onset gates. - - entry pitch handoff is therefore enabled only for explicit continuous/internal transitions (`soft_legato`, `internal_bend`, `internal_vibrato`, or adjacent edited notes inside the same island). Hard/unknown entries keep dry-protected audio ownership and render-context pre-roll without delaying the shifted body. - - diagnostics now include `noteHqEntryPitchHandoffUsed`, handoff start/end, pre/body milliseconds, slope-jump, and acceleration-limit status; the frontend bridge and regression summaries preserve those fields. - - the harness now reports entry F0 slope/acceleration metrics. The hard gate applies only when a real pitch handoff is used; canonical hard/unknown step edits still report the metric as diagnostic because the reference itself behaves like a step edit. - - verification: - - `D:\test projects\os tests\runs\20260428_115125_entry_contour_handoff_plus4_final3`: `pitchOrg +4` passed; onset artifact `1.501`, onset derivative `1.05`, exit-next `2.156`, body/core pitch `0.00/0.00 cents`, harmonic drift `0.382`. - - `D:\test projects\os tests\runs\20260428_115314_entry_contour_handoff_minus4_final`: `pitchOrg -4` passed; onset artifact `1.187`, onset derivative `0.92`, exit-next `0.207`, downshift harmonic drift `0.339`, low/mid/high `-0.911/-2.011/-0.202 dB`. - - `D:\test projects\os tests\runs\20260428_115719_entry_contour_handoff_two_adjacent_plus4_r2`: adjacent selected notes still render as one edit island (`noteHqEditIslandCount=1`, `noteHqEditedNoteCount=2`) and report the internal pitch handoff instead of a second dry/wet bridge. -- 2026-04-28 emergency word-grouping repair: - - product default is restored to single-note ownership: clicking or dragging one note selects and edits only that note. - - `wordGroupId` remains metadata for diagnostics and controlled render-island decisions, but it no longer automatically expands visible selection or pitch-drag edits. - - the large word-group hull overlay and multi-note "Word" inspector display were removed because broad/incorrect grouping made unrelated notes appear and move together. - - analyzer merging is conservative again: automatic merges use a short `40 ms` gap plus an about `1 st` pitch-distance guard, rather than swallowing nearby material solely because it is within the dropout bridge. - - strong acoustic `hard_word_like` boundary candidates may split default analyzer regions; pure pitch hysteresis, pitch corner, and `internal_vibrato` candidates remain non-destructive diagnostics. - - harness diagnostics now treat hard acoustic splits separately from destructive pitch-corner/pitch-jump failures and add expected-region overhang checks to catch collapsed words. - - verification: - - analyzer run `D:\test projects\os tests\runs\20260428_110455_emergency_word_group_repair_analysis_pitchOrg`: `noteCount=5`, `wordGroupCount=5`, `destructiveCornerSplitCount=0`, `destructivePitchJumpSplitCount=0`, hard acoustic splits `2`, max fragments `1`, max overhang `0.459`. - - UI ownership test `pitchEditorSingleNoteOwnership.test.ts`: click/select, drag update, and selected pitch move all affect only explicit note IDs even when notes share `wordGroupId`. - - primary note-HQ runs `D:\test projects\os tests\runs\20260428_110516_emergency_repair_pitchOrg_plus4` and `D:\test projects\os tests\runs\20260428_110641_emergency_repair_pitchOrg_minus4` passed the current gates; measured onset artifacts were `1.48` and `1.598`, exit-next artifacts `2.31` and `0.091`, downshift harmonic drift `0.343`. - - adjacent selected-note run `D:\test projects\os tests\runs\20260428_110807_emergency_repair_two_adjacent_plus4` reported `noteHqEditIslandCount=1` and `noteHqEditedNoteCount=2`; it is kept as the double-voice safety check, with exit-next artifact still a polish risk on that stress fixture. -- 2026-04-28 phrase-first vibrato-safe word detection: - - the older running-average pitch-jump split is no longer allowed to destructively cut editable notes; sustained pitch movement now becomes `boundaryCandidates` with `pitch_hysteresis_*` reasons. - - analyzer segmentation is phrase-first: short voiced detector dropouts up to `80 ms` are bridged, while automatic note cuts are reserved for hard acoustic evidence such as long unvoiced gaps or sustained energy breaks. - - vibrato-like periodic reversals are reported as `internal_vibrato` diagnostics and remain non-destructive by default. - - close fragments are merged across short non-hard gaps without blocking on pitch distance alone, so melisma/bend/vibrato movement does not create separate editable words. - - superseded by the emergency repair above: `wordGroupId` is assistive metadata, not default UI ownership. - - harness diagnostics now also report `pitchDeviationCandidateCount`, `destructivePitchJumpSplitCount`, and `vibratoSuppressedCandidateCount`; destructive pitch-jump splits must be `0` by default. - - verification: - - analyzer run `D:\test projects\os tests\runs\20260428_040921_phrase_first_word_detection_pitchOrg`: `noteCount=3`, `wordGroupCount=3`, `destructivePitchJumpSplitCount=0`, `destructiveCornerSplitCount=0`, edited-word overlap `1.000`, max fragments `1`. - - primary note-HQ runs `D:\test projects\os tests\runs\20260428_040951_phrase_first_pitchOrg_plus4` and `D:\test projects\os tests\runs\20260428_041117_phrase_first_pitchOrg_minus4` kept the prior pitch, onset, exit, and downshift formant gates. - - adjacent-fragment diagnostic `D:\test projects\os tests\runs\20260428_041244_phrase_first_two_adjacent_plus4` reported `noteHqEditIslandCount=1` and `noteHqEditedNoteCount=2`, confirming one ownership island for two supplied fragments. - - lesson: demoting pitch-corner splits was not enough because the older pitch-deviation splitter could still fragment continuous sung words before word grouping ran. -- 2026-04-28 word-group + edit-island correction: - - pitch-curve corners are now exported as boundary candidates instead of destructive note splits by default; `OPENSTUDIO_ANALYZER_APPLY_CORNER_SPLITS=1` is research-only. - - analyzer output now includes `wordGroupId`, so close voiced fragments without a hard acoustic break remain one editable word/phrase group. - - superseded by the emergency repair above: normal pitch moves affect only explicitly selected notes. - - final note-HQ builds commit ownership per edit island, not per note, so adjacent moved notes get one outer entry bridge and one outer exit bridge instead of internal dry/wet handoffs. - - merged commit ranges no longer average pitch ratios with `sqrt(previous * current)`; variable-ratio islands keep ownership separate from the actual per-sample pitch curve. - - harness diagnostics now report boundary candidates, destructive corner split count, word-group overlap, edit-island count, and edited-note count. - - verification: - - analyzer run `D:\test projects\os tests\runs\20260428_022002_word_group_analysis_pitchOrg_r2`: `noteCount=10`, `wordGroupCount=4`, `cornerCandidateCount=2`, `destructiveCornerSplitCount=0`, min expected word-group overlap `0.928`. - - primary note-HQ runs `D:\test projects\os tests\runs\20260428_022028_word_group_island_plus4` and `D:\test projects\os tests\runs\20260428_022028_word_group_island_minus4` kept the prior +4/-4 pitch, onset, exit, and downshift formant gates. - - adjacent-fragment diagnostic `D:\test projects\os tests\runs\20260428_023520_word_group_two_adjacent_plus4_r4` reported `noteHqEditIslandCount=1` and `noteHqEditedNoteCount=2` in the raw result, confirming one ownership island for two moved fragments. - - lesson: more segmentation can improve seam metrics while making vocal editing worse; editable note boundaries, vocal word groups, and render islands must stay separate. -- 2026-04-27 segmentation + timbre-stability update: - - note segmentation now has conservative pitch-corner boundary detection: sharp smoothed-F0 direction reversals can split a note only when supported by energy, confidence, nearby unvoiced/noise, or strong pitch-prominence evidence. - - detected note boundaries now carry `entryBoundaryKind` / `exitBoundaryKind` diagnostics (`hard_word_like`, `soft_legato`, `internal_bend`, or `unknown`) plus reason and score. - - note-HQ commit policy is boundary-kind aware: hard word-like entries get a tighter audible bridge, while soft legato/sustain entries may keep the wider phrase bridge needed for continuous vocal gestures. - - downshift pitch-only renders now apply voiced-core spectral envelope transfer on edited note bodies after the native directional render; this makes formant stability depend on the original vowel envelope rather than only on ratio compensation. - - the failed lesson is recorded: smoothing the wrong detected note boundary can move the stitch artifact around without fixing it, so analysis boundaries, vocal boundaries, and render ownership are now treated separately. - - primary verification: - - `D:\test projects\os tests\runs\20260428_013135_seg_corner_timbre_plus4`: body/core `0.00 / 0.00 cents`, onset artifact `1.479`, onset derivative `1.087`, exit-next `2.307`, harmonic drift `0.379`. - - `D:\test projects\os tests\runs\20260428_013650_seg_corner_timbre_minus4_mix005`: body/core `0.00 / +11.90 cents`, `spectralEnvelopeCorrectionUsed=true`, onset artifact `1.598`, onset derivative `1.598`, exit-next `0.091`, harmonic drift `0.343`, low/mid/high `-1.083 / -2.067 / -0.053 dB`. - - rejected aggressive envelope-transfer run `D:\test projects\os tests\runs\20260428_013344_seg_corner_timbre_minus4` regressed harmonic drift to `0.615`, so the kept pass uses a small voiced-support-weighted transfer mix. - - analyzer diagnostic run `D:\test projects\os tests\runs\20260428_013900_seg_corner_boundary_analysis` serialized boundary diagnostics and reported `2` corner-boundary notes; use manual vocal-boundary references before increasing split aggressiveness. -- 2026-04-27 signal-chain correctness update: - - native pitch-only renders no longer pass detected F0 curves through the `formantRatios` argument by accident; pitch-only entrypoints now route `ratios + detectedPitchHz` separately and keep `formantCurveUsed=false`. - - note-HQ compare semantics are corrected: preview segments are window-local, but phrase/full-clip note-HQ candidates are scored as full-clip audio. - - note-HQ apply now renders with phrase/transition context but dry-protected final compositing keeps audio before the edited note start dry; `pitchOrg` renders with context around `0.604s-1.683s`, effective ownership `0.860s-1.610s`, and audible commit `0.900s-1.610s` for a `0.900s-1.550s` note body. - - follow-up direction-specific decision: production note-HQ pitch-only defaults to native directional HQ; Rubber Band/offline HQ remains benchmark-only unless explicitly requested. - - follow-up runtime fix: `tools/rubberband/sndfile.dll`, `vcruntime140.dll`, and `vcruntime140_1.dll` are now bundled so Rubber Band starts and reports version `4.0.0`; `20260427_145800_rubberband_runtime_fixed_pitchOrg_plus4` confirms `phraseHqExternalUsed=true`. - - external Rubber Band HQ is still benchmark-gated, not quality-promoted: `20260427_145847_rubberband_runtime_fixed_pitchOrg_plus4` failed strict formant/boundary gates (`midBandDeltaDb=+7.866`, boundary `38.54 ms`). - - final diagnostic native-fallback runs: - - `20260427_135220_after_fix_pitchOrg_plus4_native_override_final`: body/core pitch `0.00/0.00 cents`, formant body drift `0.378`, low/mid/high `-2.46/+0.53/-3.59 dB`, core F1/F2 drift `-46.9/+23.4 Hz`, boundary `8.42 ms`. - - `20260427_135413_after_fix_pitchOrg_minus4_native_override_final`: body/core pitch `0.00/+11.90 cents`, formant body drift `0.469`, low/mid/high `-0.41/+0.83/+1.12 dB`, core F1/F2 drift `+46.9/-70.3 Hz`, boundary `25.38 ms`. - - richer formant suite passed under explicit native fallback: `20260427_135901_after_fix_formant_richer_native_override`. - - richer transient suite passed under explicit native fallback: `20260427_141431_after_fix_transient_richer_native_override`. - - boundary-variant suite is not fully green under the new strict gate: `20260427_142907_after_fix_boundary_pitchOrg_plus4_native_override` passed `start_earlier` and `start_later`, then failed synthetic `end_earlier` at boundary timing `38.938 ms > 32 ms`. -- Shipping fallback: `branch_hybrid_reset` -- Trusted experimental control: `pitch_only_hybrid_structural` (`HS-4 / r6`) -- Active best experimental branch: `pitch_only_adaptive_selector` with harvested long-upward and short-downward support -- Analyzer close-out state: direct-YIN + decoder is the frozen kept path; FFT-YIN stays rejected-for-now behind `OPENSTUDIO_ANALYZER_USE_FFT_YIN=1` -- Scrub preview state: natural-segment scrub preview is now active and benchmarked via `20260416_200227_pitchOrg_scrub_preview_r8` (`scrubPreviewAudible=true`, `scrubPreviewFirstDragAudible=true`, loop duration `240 ms`, base pitch `365 Hz`, last peak `0.112`); repeat-stability tuning is still open -- Root-cause research state: - - completed in `20260417_012542_pitch_root_cause_research` - - ranked causes: - - transition ownership and boundary timing drift - - mixed transient and first-voiced-cycle handling inside one renderer family - - formant preservation that is too weak and too local for hard transitions - - repo summary: - - [pitch_root_cause_research_20260417.md](c:/Users/srvds/Documents/Codes/Studio13-v3/docs/pitch_root_cause_research_20260417.md) -- ML benchmark state: - - completed in `20260417_003355_pitch_ml_benchmark` - - verdict: `blocked_no_stronger_restorer` - - local environment has no materially stronger note-local restorer ready now -- Engine-v3 feasibility state: - - completed in `20260417_003355_engine_v3_feasibility` - - `V3-1` decomposition probe verdict: `stop` - - do not open a longer engine-v3 implementation branch from the current decomposition probe -- Harness close-out state: - - `H2` boundary suite is implemented - - `H1` scrub suite is still partial, but it now has richer multi-scenario runs on both canonical clip families: - - `20260416_231821_pitchOrg_scrub_suite_richer_r1` - - `20260416_234516_pitchTest_scrub_suite_richer_r1` - - true multi-note scrub fixtures now exist: - - `tests/fixtures/pitch-regression/example_pitchOrg_scrub_multinote.json` - - `tests/fixtures/pitch-regression/example_pitchTest_scrub_multinote.json` - - multi-note scrub suite run: - - `20260416_235640_pitchTest_scrub_suite_multinote_r3` - - first drag, repeated drag, after-transport-cycle, and selection-change are now all exercised end-to-end - - `H1` scrub suite is now complete for the current canonical local fixture corpus - - `H3` transient suite is now complete for the current canonical fixture corpus with richer up/down boundary-focused coverage: - - `20260416_231844_transient_suite_richer_r1` - - `H4` formant suite is now complete for the current canonical fixture corpus with richer body/transition coverage: - - `20260416_233426_formant_suite_richer_r1` - - first adaptive-carrier boundary/formant correction pass is now benchmarked: - - latest run: `20260416_224905_adaptive_stft_tune_r10_*` - - `spectralEnvelopeCorrectionUsed=true` on both `+4` truth clips - - result: best adaptive correction profile so far, still mixed and not keepable yet - - `A7` STFT sweep is now closed as effectively flat (`1024/8`, `2048/8`, `2048/4` all matched within noise) - - engine-v2 challenger close-out: - - run: `20260416_230357_enginev2_narrow_pitchOrg_plus4_r8` - - run: `20260416_230555_enginev2_narrow_pitchTest_plus4_r8` - - verdict: freeze challenger; still loses clearly on the hard clip - - remaining mandatory iterations from the previous close-out program: `0` - - remaining conditional iterations from the previous close-out program: `+2` -- Current renderer research reference: [pitch_renderer_research_notes.md](c:/Users/srvds/Documents/Codes/Studio13-v3/docs/pitch_renderer_research_notes.md) -- Pitch editor scope: monophonic only, with stereo vocal clips supported by analyzing a mono sum while preserving multichannel render output -- Canonical truth cases: - - `pitchOrg.wav -> pitchOrg+4s.wav` - - `pitchOrg.wav -> pitchOrg-4s.wav` - - `pitchTestOrg.wav -> pitchTestOrg+4s.wav` - - `pitchTestOrg.wav -> pitchTestOrg-4s.wav` -- Workflow rules: - - Track every family here first. - - Test one active family at a time. - - Compare every run against `CTRL-SHIP` and `CTRL-R6`. - - Maximum `2` serious iterations per family. - - If a family is rejected, remove renderer-specific code quickly and record the rejection in the chronological log. - -## 2026-04-27 Production Note-HQ Directional Update -- Classification: signal-chain correctness correction, not a reopened renderer-family experiment. -- Product decision: - - `note_hq` pitch-only production rendering now defaults to native direction-specific HQ. - - Upward edits keep the existing native adaptive path. - - Superseded carrier detail: downward edits used a gentler formant guard for a period, but pitch-only Signalsmith now uses neutral formant preservation and leaves downshift timbre support to bounded post-render correction. - - Rubber Band remains available for diagnostics and benchmark runs, but it is not quality-promoted for production `note_hq` unless `OPENSTUDIO_PITCH_USE_RUBBERBAND_HQ=1`. -- Downshift timbre fix: - - native pitch-only downshifts now keep the Signalsmith carrier formant-neutral with `setFormantFactor(1.0f, true)`. - - active pitch-only carriers keep detected F0 as `setFormantBase(...)` guidance and use the live-preview stage-A tonality limit before applying neutral formant compensation. - - the adaptive selector's bounded own-engine downshift blend now defaults to `0.42`; envelope matching remains the secondary bounded body-color support focused on the voiced `200-3500 Hz` body while protecting transient and air bands. -- Boundary polish: - - note-HQ ownership is now asymmetric around edited notes, with a wider post-note shoulder and a small next-note head when adjacent notes touch. - - final compositing uses a wider dry-protected crossfade at the effective commit range instead of cutting at the note body boundary. - - the regression harness now reports an explicit exit-to-next-note artifact score. -- 2026-04-27 two-sided boundary correction: - - the first exit-focused fix exposed an audible pre-note/previous-word handoff because the dry-protected compositor could become fully wet before the edited note body started. - - commit ranges now carry both the effective shoulder and the true note body start/end. - - final audible compositing now starts at the edited note body start, never at the left effective shoulder by default. - - `[effectiveStartTime, note.startTime)` is copied from the original/dry audio exactly; the dry-to-wet entry fade happens inside the first `12 ms` of the edited note body. - - release compositing starts a small `12 ms` lead-in before the note body end, then fades through the right shoulder, reducing the derivative jump at the edited-note exit. - - the failed lesson is recorded: widening left shoulder ownership can move the stutter backward into the previous word, so render context and audible commit ownership must stay separate. - - the harness now reports `preBodyTailOriginalResidualDb`, `candidateActiveDifferenceStartSec`, `preBodyTailArtifactScore`, and writes `orig_pre_body_tail.wav`, `cand_pre_body_tail.wav`, and `diff_pre_body_tail.wav`. -- Primary measured results: - - `pitchOrg +4`: run `tmp_pitch_runs/20260427_160222_direction_guard_v3_pitchOrg_plus4`; body/core cents `0.00 / 0.00`, harmonic drift `0.378`, band deltas `-2.464 / +0.531 / -3.590 dB`, F1/F2 proxy drift `-46.875 / +23.438 Hz`, onset artifact `1.79`, exit-next artifact `1.110`. - - `pitchOrg -4`: run `tmp_pitch_runs/20260427_160222_direction_guard_v3_pitchOrg_minus4`; body/core cents `0.00 / +11.90`, harmonic drift improved from the prior native `0.469` to `0.353`, band deltas `-0.420 / -1.280 / +0.208 dB`, onset artifact `1.40`, exit-next artifact `1.927`. - - two-sided boundary follow-up `pitchOrg +4`: run `tmp_pitch_runs/20260427_202041_direction_entry_guard_v6_plus4`; pre-commit/onset artifacts `0.055 / 1.791`, exit-next artifact `2.307`, body/core cents `0.00 / 0.00`. - - two-sided boundary follow-up `pitchOrg -4`: run `tmp_pitch_runs/20260427_202204_direction_entry_guard_v6_minus4`; pre-commit/onset artifacts `0.093 / 1.398`, exit-next artifact `0.091`, harmonic drift `0.352`, body/core cents `0.00 / +11.90`. - - final pre-body dry ownership `pitchOrg +4`: run `tmp_pitch_runs/20260427_213815_pre_body_dry_v3_plus4`; body/core cents `0.00 / 0.00`, pre-body residual `-170.878 dB`, active difference start `0.900063s`, onset artifact `1.706`, exit-next artifact `2.307`, harmonic drift `0.379`. - - final pre-body dry ownership `pitchOrg -4`: run `tmp_pitch_runs/20260427_213940_pre_body_dry_v3_minus4`; body/core cents `0.00 / +11.90`, pre-body residual `-169.515 dB`, active difference start `0.900063s`, onset artifact `2.721`, exit-next artifact `0.091`, harmonic drift `0.352`. - - entry-bridge follow-up: - - product rule is now bridge-aware: render context may extend before the edited note, and final apply may use a bounded entry bridge, but audio before `noteHqEntryBridgeStartSec` must remain original/dry. - - upward edits use a tight in-body bridge (`0.900s -> 0.916s` on `pitchOrg +4`) because the pre-note bridge regressed the upward onset audit. - - downward edits use a bounded pre-note bridge (`0.876s -> 0.980s` on `pitchOrg -4`) with a `-22.0 ms` wet-read offset, `+1.7 dB` entry envelope correction, and `10 ms` dry transient preservation. - - the corrected lesson is recorded: fully dry pre-note ownership prevented previous-word mutation, but could leave a phase/envelope discontinuity exactly at the edited-note entry. - - final `pitchOrg +4` run `tmp_pitch_runs/20260428_004839_entry_bridge_v15_plus4`: body/core cents `0.00 / 0.00`, entry lag `-0.125 ms`, onset artifact `1.479`, onset derivative `1.087`, protected pre-bridge residual `-172.823 dB`, exit-next artifact `2.307`, harmonic drift `0.379`. - - final `pitchOrg -4` run `tmp_pitch_runs/20260428_004708_entry_bridge_v15_minus4`: body/core cents `0.00 / +11.90`, entry lag `+0.771 ms`, onset artifact `1.598`, onset derivative `1.598`, protected pre-bridge residual `-240.000 dB`, exit-next artifact `0.091`, harmonic drift `0.344`. - - export parity passed in `tmp_pitch_runs/20260428_005155_entry_bridge_v15_plus4_export` and `tmp_pitch_runs/20260428_005458_entry_bridge_v15_minus4_export`; the source-vs-export dry residual remains informational because the mixer/export path changes the full file from time zero. - - Export parity passed for both directions in `tmp_pitch_runs/20260427_214538_pre_body_dry_v4_plus4_export` and `tmp_pitch_runs/20260427_214814_pre_body_dry_v4_minus4_export`; the source-vs-export dry residual readout is informational only because the mixer/export path changes the full file from time zero. - - Richer formant suite passed in `tmp_pitch_runs/pre_body_dry_v2_formant_richer/20260427_212751_pre_body_dry_v2_formant_richer`. - - Richer transient suite passed in `tmp_pitch_runs/pre_body_dry_v4_transient_richer/20260427_215055_pre_body_dry_v4_transient_richer`. - - Boundary suite completed in `tmp_pitch_runs/pre_body_dry_v2_boundary/20260427_212152_pre_body_dry_v2_boundary`; the primary real `pitchOrg` cases stay under the exit-next gate, while the synthetic shortened-end stress case still shows a high exit-next artifact and remains a stress warning. -- Rubber Band benchmark status: - - runtime availability diagnostics stay in place. - - pitch maps now use `effectiveStart/effectiveEnd` transition shoulders for benchmark ramps. - - current benchmark evidence is not production-promotable because the earlier `+4` run failed the mid-band formant gate and had worse boundary timing than native. - -## Approach Status Table -| Family ID | Approach | Class | Status | Last Result | Best Observed Gain | Main Failure | Can Harvest | Code State | Next Action | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `CTRL-SHIP` | Shipping fallback `branch_hybrid_reset` | Control | `Kept Control` | Shipping-safe app path; still current fallback baseline | Safest all-around fallback, exact pitch on truth cases | Still far from samples on onset, exit, neighbors | Yes | `Active branch` | Keep frozen as shipping control | -| `CTRL-R6` | `pitch_only_hybrid_structural` / `HS-4 / r6` | Control | `Kept Control` | Trusted experimental baseline; best kept short-upward branch | `pitchOrg +4` note mel `6.209`, env `0.961`, entry `6.928`, exit `6.076`, onset artifact `1.810` | Does not improve `pitchTest +4`; stutter still present | Yes | `Active branch` | Keep frozen as experimental control | -| `FAM-ANALYZER-PYIN` | Native editor-first analyzer upgrade: staged direct-YIN baseline plus FFT-YIN probe, multi-candidate extraction, voiced/unvoiced probabilities, and Viterbi-style decoding in `PitchAnalyzer` | Analysis upgrade | `Promising` | 2026-04-15 close-out: direct Hann-windowed YIN + decoder matched the target note window on both analysis fixtures (`pitchOrg`: `7` detected / `1` expected, overlap ratio `0.964`; `pitchTestOrg`: `6` detected / `1` expected, overlap ratio `0.796`) with high median voiced confidence (`0.974` / `0.976`), while FFT-YIN produced `0` detected notes and `0.000` voiced-frame ratio on both clips when forced on | First native editor-side path that adds pYIN-like candidate generation and temporal decoding without new dependencies, while keeping the editor contract stable and the direct path usable on real clips | Direct path still oversplits full-clip note segmentation on the current fixtures, and FFT-derived difference computation is still not parity-safe enough to promote | Yes | `Active branch` | Freeze the direct-YIN + decoder path as the kept analyzer implementation, keep FFT-YIN gated off, and treat any future analyzer work as segmentation polish rather than an open parity question | -| `FAM-SCALAR` | Scalar blend/ramp/timing/smoothing/coherence/bridge family | Exhausted tweak family | `Rejected` | Multiple rejected HS iterations; no keep beyond `HS-4 / r6` | Safer short-upward early-core blend in `HS-4` | Could not fix onset/body tradeoff; all later variants plateaued | Yes | `Disabled` | Do not revisit without a new structural reason | -| `FAM-BODY-A` | Dry attack + existing own-engine body | Body replacement | `Rejected` | Path A iteration 1 | Onset artifact improved on `pitchOrg +4` | Note mel/env and entry worsened; no equal-weight win | No | `Disabled` | Keep rejected | -| `FAM-BODY-B` | Dry attack + epoch-copy body | Body replacement | `Rejected` | Path B exhausted after 2 iterations | Strong note-body gain on `pitchOrg +4` | Onset collapsed badly; not keepable | Yes | `Disabled` | Harvest only if a future v2 needs epoch-copy body evidence | -| `FAM-BODY-C` | Dry attack + harmonic-only body | Body replacement | `Rejected` | Path C exhausted after 2 iterations | None; C1 failed closed | C2 collapsed pitch/body badly | No | `Disabled` | Keep rejected | -| `FAM-BODY-D` | Dry attack + PSOLA body | Body replacement | `Rejected` | Path D exhausted after 2 iterations | Best body realism on `pitchOrg +4`: note mel `5.105`, env `0.474`, harmonic drift `0.127` | Onset artifact stayed far worse than `r6`; did not generalize to `pitchTest +4` | Yes | `Disabled` | Harvest PSOLA body realism only, not the handoff architecture | -| `FAM-CONT-E1` | Voiced-tail continuation body | Continuation | `Rejected` | Failed closed on first iteration | None | Never engaged on active target | No | `Disabled` | Keep rejected | -| `FAM-CONT-E2` | Early continuation handoff into existing core | Continuation | `Rejected` | Failed closed on first iteration | None | Never engaged on active target | No | `Disabled` | Keep rejected | -| `FAM-ISLAND-F` | Fixed-mask island-native with own-engine core | Island-native | `Rejected` | Path F exhausted after 2 iterations | Onset artifact improved to `1.388` on `pitchOrg +4` | Body, entry, and exit regressed too much; never engaged on `pitchTest +4` | Yes | `Disabled` | Harvest onset-side gain only | -| `FAM-ISLAND-G` | Island-native + PSOLA core | Island-native | `Rejected` | Path G stopped after 1 iteration | Preserved Path F onset-side gain | Pitch/body/exit destabilized badly; still no `pitchTest +4` engagement | No | `Disabled` | Keep rejected | -| `FAM-CE33-SIMPLE` | Legacy simple `ce33` path (`branch_simple_ce33`) | Archived baseline | `Promising` | Truth sweep 2026-04-15; `pitchOrg +4` note mel `7.810`, env `0.883`, entry `7.996`, exit `9.295`, onset artifact `2.50`; `pitchTest +4` note mel `3.044`, env `0.486`, entry `1.660`, exit `6.378`, onset artifact `0.72` | Best measured `pitchTest +4` single-case result so far | Still behind `CTRL-R6` on `pitchOrg +4`; not a single best overall control | Yes | `Active branch` | Keep as a secondary benchmark and harvest candidate, not as the main control | -| `FAM-ADAPTIVE-SELECTOR` | Harvested hybrid selector: `CTRL-R6` for short upward notes, `branch_simple_ce33` for long upward notes, light own-engine support on short downward notes | Hybrid kept path | `Kept Control` | 2026-04-15 broader validation sweep: `pitchOrg +4` note mel `7.085`, env `1.376`, entry `7.078`, exit `7.027`, onset artifact `1.80`; `pitchOrg -4` note mel `6.623`, env `1.102`, entry `6.585`, exit `7.475`, onset artifact `2.80`; `pitchTestOrg +4` note mel `2.810`, env `0.470`, entry `1.530`, exit `1.632`, onset artifact `0.70`; `pitchTestOrg -4` note mel `3.505`, env `1.024`, entry `3.090`, exit `1.835`, onset artifact `3.64` | First branch to beat `CTRL-R6` on `pitchTest +4`, keep the easier `+4` case competitive, and carry a useful harvested `pitchOrg -4` improvement without destabilizing the truth set | Still does not fully solve stutter/formant note-change quality, and `pitchTestOrg -4` remains only acceptable rather than a clear win | Yes | `Active branch` | Freeze as the benchmark branch for the rest of the research close-out and any engine-v2 comparisons | -| `FAM-ADVANCED` | Advanced legacy branch (`branch_current_advanced`) | Archived baseline | `Rejected` | Truth sweep 2026-04-15; `pitchOrg +4` note mel `7.329`, env `0.827`, cents `-36.45`; `pitchTest +4` note mel `8.784`, env `1.190`, cents `-35.70` | None beyond historical reference value | Misses pitch on both truth cases and loses clearly to both controls | No | `Active branch` | Do not use standalone; prune after tracker-based cleanup reaches archived branches | -| `FAM-CORE-PSOLA` | Standalone PSOLA core (`pitch_only_psola_core`) | Archived core | `Rejected` | Truth sweep 2026-04-15; `pitchOrg +4` note mel `11.053`, env `1.640`, body cents `-55.55`; `pitchTest +4` note mel `17.536`, env `2.547`, body cents `-104.96` | None as a standalone truth-case path | Severe pitch/body drift on both truth cases | No | `Active branch` | Do not use standalone; prune after tracker-based cleanup reaches archived cores | -| `FAM-CORE-MODEL` | Standalone model core (`pitch_only_model_core`) | Archived core | `Rejected` | Truth sweep 2026-04-15; `pitchOrg +4` note mel `10.955`, env `1.626`, body cents `-37.23`; `pitchTest +4` note mel `17.397`, env `2.530`, body cents `-104.96` | None as a standalone truth-case path | Severe truth-case loss with large body drift and poor envelope match | No | `Active branch` | Do not use standalone; prune after tracker-based cleanup reaches archived cores | -| `FAM-OWN-PITCH` | Standalone own-engine pitch path (`pitch_only_own_engine`) | Archived core | `Rejected` | Truth sweep 2026-04-15; `pitchOrg +4` note mel `6.316`, env `1.044`, entry `7.783`, exit `6.407`, onset artifact `1.59`; `pitchTest +4` note mel `12.164`, env `1.667`, entry `17.634`, exit `13.403`, onset artifact `3.99` | Decent exact-pitch `pitchOrg +4` body with a better onset artifact than `CTRL-R6` | Collapses badly on `pitchTest +4`; not a viable standalone editor | Yes | `Active branch` | Harvest only if a future v2 needs own-engine core behavior on easy upward clips | -| `FAM-FORMANT-ONLY` | Own-engine formant-only path (`formant_only_own_engine`) | Archived formant path | `Rejected` | Truth sweep 2026-04-15 matched `FAM-ADVANCED` bit-for-bit on both `+4` truth cases | None distinct from `FAM-ADVANCED` | Not a competitive truth-case path and appears equivalent to the advanced branch on current tests | No | `Active branch` | Treat as an archived alias-like formant path; prune with `FAM-ADVANCED` | -| `FAM-PITCH-PLUS-FORMANT` | Own-engine pitch-plus-formant path (`pitch_plus_formant_own_engine`) | Archived formant path | `Rejected` | Truth sweep 2026-04-15 matched `FAM-ADVANCED` bit-for-bit on both `+4` truth cases | None distinct from `FAM-ADVANCED` | Not a competitive truth-case path and appears equivalent to the advanced branch on current tests | No | `Active branch` | Treat as an archived alias-like formant path; prune with `FAM-ADVANCED` | -| `FAM-BASELINE-SAFE` | `baseline_safe` runner option | Alias / cleanup item | `Superseded` | Code inspection 2026-04-15: runner validate-set contains it, renderer parser does not map it to a distinct branch | None; not a distinct renderer family | Appears to be an alias/config leftover rather than a real option | No | `Not started` | Treat as non-distinct; clean up the option when touching runner dispatch next | -| `FAM-V2-SYNTH-CORE` | Island shell + directly synthesized voiced core + explicit residual layer | Major revamp | `Rejected` | `G1` on 2026-04-15; `pitchOrg +4` onset artifact `1.810 -> 1.39` but note mel `6.209 -> 8.135`, env `0.961 -> 1.517`, entry `6.928 -> 10.468`, exit `6.076 -> 13.781`, whole-note cents `-408.27`; `pitchTest +4` failed closed to control SHA | Preserved the familiar onset-side gain pattern on `pitchOrg +4` | Body, entry, exit, and pitch stability regressed too much; no reason to spend `G2` | No | `Removed` | Do not continue this family; escalate to a deeper redesign definition | -| `FAM-V2-HSR` | Transient layer + harmonic/source-filter core + residual layer | Major revamp | `Rejected` | `pitch_only_engine_v2` G1 on 2026-04-15; `pitchOrg +4` onset artifact `1.810 -> 1.388` but note mel `6.209 -> 7.975`, env `0.961 -> 1.454`, entry `6.928 -> 9.737`, exit `6.076 -> 12.703`; `pitchTest +4` unchanged and still did not engage | Onset-side gain on `pitchOrg +4` matched island-native family | Body/exit regressed too much and the family still did not generalize to `pitchTest +4` | No | `Removed` | Do not continue this family; move to a deeper redesign definition | -| `FAM-HPSS-SHELL` | HPSS-style vertical split: original transient/noise shell plus pitched harmonic layer | Major revamp | `Rejected` | `H1` on 2026-04-15; `pitchOrg +4` engaged and improved onset artifact `1.810 -> 1.390`, but note mel worsened `6.209 -> 10.066`, env `0.961 -> 2.028`, entry `6.928 -> 10.996`, exit `6.076 -> 15.137`; `pitchTest +4` failed closed with `hpssUsed=false`; `-4` guards also lost versus the adaptive control | Another confirmation that vertical transient preservation can reduce the onset score on the easy upward clip | Harmonic body collapsed badly on `pitchOrg +4`, did not engage on `pitchTest +4`, and did not stay safe on the `-4` guards | No | `Removed` | Stop the family at `H1`; do not open `H2/H3` on this shell | -| `FAM-HPSS-SF` | HPSS shell plus source-filter harmonic core | Major revamp | `Researched` | Not started because `FAM-HPSS-SHELL` failed the stop-fast gate immediately | Plausible path for vertical split plus timbre preservation if a stronger shell ever wins | Blocked by the failed shell; current planned version should not be opened on top of a losing `H1` | Yes | `Not started` | Only revisit if a structurally different HPSS shell is defined later | -| `FAM-HPSS-SF-APER` | HPSS shell plus source-filter core plus explicit aperiodic layer | Major revamp | `Researched` | Not started because `FAM-HPSS-SHELL` failed the stop-fast gate immediately | Keeps the full transient/harmonic/aperiodic decomposition idea on the map | Blocked by the failed shell; should not be layered onto the rejected `H1` foundation | Yes | `Not started` | Only revisit if a later HPSS shell earns continuation | -| `FAM-WSOLA-SEAM` | WSOLA-style shoulder similarity search on top of the adaptive selector | Major revamp | `Rejected` | `W1` on 2026-04-15; only `pitchOrg +4` changed, and it got worse versus the current adaptive branch: note mel `7.085 -> 7.102`, env `1.376 -> 1.377`, entry `7.078 -> 7.314`, exit `7.027 -> 7.151`, onset artifact `1.80 -> 2.29`; `pitchOrg -4`, `pitchTestOrg +4`, and `pitchTestOrg -4` stayed byte-identical to the adaptive branch | Proved the repo can run seam-search diagnostics through the existing truth harness | No truth-case win, and the only engaged case regressed on onset, entry, exit, and note mel | No | `Removed` | Do not revisit this shoulder-only WSOLA implementation; move to phase-coherent DSP families | -| `FAM-PHASE-LOCK-PV` | Phase-vocoder body path with harmonic peak locking and boundary phase alignment | Major revamp | `Rejected` | `P1` on 2026-04-15; `pitchOrg +4`, `pitchOrg -4`, and `pitchTestOrg -4` stayed byte-identical to `FAM-ADAPTIVE-SELECTOR`; `pitchTestOrg +4` was the only engaged case with `phaseLockUsed=true`, `phaseAlignedExit=true`, `phasePeakCount=114`, but it regressed on note mel `2.81 -> 3.003`, entry mel `1.53 -> 2.278`, and exit mel `1.632 -> 1.940` while only slightly improving onset artifact `0.705 -> 0.66` | Proved this lighter boundary-phase-alignment variant can engage selectively on the harder long-upward case without destabilizing the other truth cases | No equal-weight win: the only engaged case materially worsened note and entry metrics, so it did not earn a tuned `P2` | No | `Removed` | Do not revisit this boundary-alignment-only phase-lock pass; move to `FAM-HPSS-MEDIAN` | -| `FAM-HPSS-MEDIAN` | True median-filter HPSS shell with harmonic/transient recombine | Major revamp | `Rejected` | `Hm1` on 2026-04-15; only `pitchOrg +4` changed, with `hpssUsed=true`, harmonic/aperiodic peaks `0.868 / 0.554`, but note mel worsened `7.085 -> 7.432`, entry mel `7.078 -> 7.445`, and note/body/core cents collapsed to `-55.55`; `pitchOrg -4`, `pitchTestOrg +4`, and `pitchTestOrg -4` stayed byte-identical to `FAM-ADAPTIVE-SELECTOR` | Proved the repo can run a real median-filter HPSS shell through the truth harness instead of just the earlier heuristic mask shell | Still did not generalize to `pitchTest +4`, and the only engaged case regressed on body pitch and note/entry quality | No | `Removed` | Do not revisit this scalar median-shell implementation; keep only the diagnostics and stop list entry | -| `FAM-HPSS-MEDIAN-SF` | Median HPSS shell plus harmonic spectral-envelope preservation | Major revamp | `Researched` | Not started because `FAM-HPSS-MEDIAN` failed the stop-fast gate at `Hm1` | Puts true HPSS and explicit harmonic timbre preservation together in one family | Gated behind a promising `FAM-HPSS-MEDIAN` shell result; should not open on a losing shell | Yes | `Not started` | Leave blocked unless a materially stronger median HPSS shell is defined later | -| `FAM-PVDR` | Research-grade phase-coherent phase-vocoder family with proper phase locking or phase-gradient integration | Major revamp | `Rejected` | `P1` on 2026-04-15 benchmarked a long-upward resample-plus-phase-lock PVDR overlay on top of `pitch_only_adaptive_selector`; `pitchOrg +4` stayed byte-identical, but `pitchTestOrg +4` catastrophically failed with note/body/core cents `-628.27 / -628.27 / -664.72`, onset artifact exploding to `+200512709616936000`, and `phaseLockUsed=true`, `phasePeakCount=14710` | Proved the repo can route a genuinely different PV-style long-note benchmark through the truth harness without touching the live branch | The first real PVDR attempt was numerically unstable and failed the stop-fast gate decisively on the harder truth case, and there is no materially different stable PV implementation ready locally right now | No | `Removed` | Keep the broader PV family closed unless a genuinely different phase-gradient or otherwise more stable implementation is ready to benchmark | -| `FAM-TRANSITION-HQ` | HQ-only transition-native note-change overlay on top of `pitch_only_adaptive_selector` | Major revamp | `Rejected` | `M1` on 2026-04-15 benchmarked `pitch_only_transition_hq`; it engaged on both `+4` truth cases with transition diagnostics, but `pitchOrg +4` regressed from note mel `7.085 -> 7.395`, env `1.376 -> 1.434`, entry `7.078 -> 7.723`, onset artifact `1.80 -> 1.59`, while `pitchTestOrg +4` regressed much harder from note mel `2.810 -> 4.084`, env `0.470 -> 0.772`, entry `1.530 -> 15.735`, onset artifact `0.70 -> 3.73`, and core cents stayed off at `-18.32` | Proved the repo can benchmark a transition-focused HQ overlay with explicit shell/core/residual diagnostics on stereo-safe mono analysis | The overlay harmed both primary truth cases, especially `pitchTestOrg +4`, so it did not earn a second DSP iteration or the optional ML finish | No | `Removed` | Stop this family at `M1`; the next escalation is not another local overlay but a materially larger engine redesign if we revisit note-change rendering | -| `FAM-ML-RESTORATION` | Offline-only post-render restoration/refinement benchmark | Research only | `Blocked` | `M1` on 2026-04-15 benchmarked `ml_restore_proxy_v1` on top of `pitch_only_adaptive_selector`; it helped the easier clip but materially harmed `pitchTestOrg +4`. Follow-up environment benchmark on 2026-04-17 returned `blocked_no_stronger_restorer`: `voicefixer` and `demucs` are not installed, `audio_separator` is not suitable for note-local restoration, and the proxy path is explicitly excluded. | First offline benchmark infrastructure that can score a restoration pass on top of the kept renderer without touching the live editor path | No materially stronger local note-local restorer is available right now, so reopening the family locally would just repeat the rejected proxy path | Yes | `Disabled` | Keep the benchmark harness and diagnostics, but only reopen this family once a genuinely stronger local or external/licensed restorer is available | -| `FAM-ENGINE-V2` | Full note-transition-aware engine redesign with explicit harmonic core, residual/noise path, formant envelope, and transition compositor | Full redesign fallback | `Frozen Reference` | `V2-1` scaffold remains parity-safe on both primary `+4` truth clips with branch `pitch_only_engine_v2_program`, and the branch contains the full transition-native audio pass: dedicated RAM scrub preview, Signalsmith-based voiced-core render, cepstral envelope restoration, spectral-flatness transient bypass, residual carry, and transition composition. Latest narrowed runs on 2026-04-16 still lose to the adaptive selector, and the 2026-04-17 root-cause pass confirmed the branch is evidence only, not the recommended active path. | The branch now has the full implementation scaffolding we actually need: note-local HQ infrastructure, dedicated scrub monitoring, transition-native diagnostics, and a benchmarkable engine-v2 sound path that no longer fails numerically | The current waveform-correction overlay shape plateaued: it can be made safer on the easy clip, but the hard truth-case entry still loses badly versus the adaptive selector | Yes | `Active branch` | Keep the code for comparison and audition, but do not continue this branch as the main recovery path without a materially stronger decomposition/transition model | -| `FAM-ENGINE-V3` | Clean-sheet transition-pair renderer with explicit transient shell, voiced core, residual path, and built-in formant handling | Long-term redesign | `Blocked` | 2026-04-17 feasibility probe `20260417_003355_engine_v3_feasibility` scored only `0.511` on `pitchOrg_plus4` and `0.505` on `pitchTest_plus4`, both with verdict `stop` at `V3-1` decomposition stage | Keeps a true clean-sheet DSP option on the map instead of another local overlay family | Current decomposition probe is not strong enough to justify immediate build-out and risks repeating engine-v2 if opened blindly | Yes | `Not started` | Only reopen after defining a materially stronger decomposition and transition-pair ownership design; do not continue from the current probe | -| `FAM-V2-HSR-DOWN` | Separate downward law on top of the active experimental branch | Major revamp | `Harvested` | Support scan plus adaptive integration on 2026-04-15: `branch_simple_ce33` was not a clean `-4` winner; `pitch_only_own_engine` improved easy downward body metrics; harvested light own-engine support into `FAM-ADAPTIVE-SELECTOR`, improving `pitchOrg -4` note mel `7.405 -> 6.570`, entry `6.902 -> 6.571`, exit `8.778 -> 7.565` while `pitchTest -4` stayed on the same SHA | Demonstrated that a small own-engine contribution helps easier short downward notes without harming the hard guard case | Harvested result still does not materially improve the harder `pitchTest -4` truth case | Yes | `Active branch` | Treat the first downward trait as harvested; only start a deeper standalone downward family if later validation shows `pitchTest -4` still needs a dedicated win | -| `FAM-V2-LONG` | Separate long-note policy on top of v2 | Major revamp | `Researched` | Not implemented yet | Lets short-note and long-note solutions diverge cleanly | Blocked until a new short-upward v2 family actually wins | Yes | `Not started` | Blocked pending a new upward v2 control | -| `FAM-NEURAL-FINAL` | Final-only restoration benchmark | Research only | `Researched` | Not implemented yet | Possible late-stage restoration after DSP render | Too early; should not be next coding task | Yes | `Not started` | Queue only if DSP v2 plateaus | -| `FAM-SIGNALSMITH-CARRIER` | Signalsmith as carrier/fallback inside a larger v2 engine | Support architecture | `Researched` | Current repo history already proves Signalsmith-family strength | Strong stability and fallback behavior | Current Signalsmith-centered handoff family is plateaued | Yes | `Active branch` | Reuse as a support role inside v2, not as more local patching | -| `FAM-WORLD-FEATURES-V2` | WORLD-style decomposition features only for envelope / aperiodicity support | Support architecture | `Researched` | Not implemented as a distinct branch; kept as an internal-analysis option only | Useful support direction for future envelope and residual estimation | Pure WORLD rendering is still out of scope and non-competitive here | Yes | `Not started` | Use only as support features inside a future renderer, never as a standalone path | -| `FAM-DECOMP-FEATURES` | WORLD-style decomposition ideas as internal features only | Support architecture | `Researched` | Research direction only; no pure WORLD retry | Useful for F0/envelope/residual decomposition signals | Pure WORLD render path is not competitive for this product | Yes | `Not started` | Use only as internal analysis support if helpful in v2 | - -## Harvestable Traits -| Trait ID | Trait | Source Family | Evidence | Adopted? | Target Architecture | -| --- | --- | --- | --- | --- | --- | -| `TR-HS4-EARLYCORE` | Softer early-core short-upward blend | `CTRL-R6` | Best kept short-upward compromise in current branch | Yes | `CTRL-R6` control | -| `TR-D-BODY` | PSOLA body realism in stable voiced core | `FAM-BODY-D` | `pitchOrg +4` note mel `6.209 -> 5.105`, env `0.961 -> 0.474`, harmonic drift `0.400 -> 0.127` | No | `FAM-V2-HSR` | -| `TR-F-ONSET` | Island-native onset improvement with outer-only splices | `FAM-ISLAND-F` | `pitchOrg +4` onset artifact `1.810 -> 1.388` | No | `FAM-V2-HSR` | -| `TR-SIGNALSMITH-STABILITY` | Stable fallback/carrier behavior | `CTRL-SHIP`, `FAM-SIGNALSMITH-CARRIER` | Shipping path remains safest exact-pitch fallback across truth cases | Yes | Shipping fallback and future v2 fallback role | -| `TR-DECOMP-SUPPORT` | Internal decomposition as analysis features, not renderer | `FAM-DECOMP-FEATURES` | Useful structural separation for F0 / envelope / residual planning | No | `FAM-V2-HSR` | -| `TR-CE33-PITCHTEST` | Strong `pitchTest +4` truth-case behavior from simple `ce33` | `FAM-CE33-SIMPLE` | `pitchTest +4` note mel `3.044`, env `0.486`, entry `1.660`, exit `6.378`, onset artifact `0.72` | No | Future hybrid / v2 benchmark role | -| `TR-OWN-PITCH-EASY-UP` | Own-engine exact-pitch short-upward behavior on easier clips | `FAM-OWN-PITCH` | `pitchOrg +4` note mel `6.316`, onset artifact `1.59`, exact body/core pitch | No | Future v2 core selection / fallback study | -| `TR-ADAPTIVE-LONGUP` | Long-upward selector rule: keep `CTRL-R6` on short upward notes and use `ce33` on long upward notes | `FAM-ADAPTIVE-SELECTOR` | `pitchTest +4` improved from `3.481 / 0.623 / 2.484 / 7.662 / 3.819` to `3.095 / 0.493 / 1.530 / 7.350 / 0.70` while `pitchOrg +4` stayed effectively flat | Yes | `FAM-ADAPTIVE-SELECTOR` | -| `TR-ADAPTIVE-DOWN-SHORT` | Light own-engine support on shorter downward notes inside the adaptive selector | `FAM-V2-HSR-DOWN` | `pitchOrg -4` improved from note mel `7.405` to `6.570`, entry `6.902` to `6.571`, exit `8.778` to `7.565`, while `pitchTest -4` stayed byte-identical and both `+4` truth cases stayed on their kept adaptive outputs | Yes | `FAM-ADAPTIVE-SELECTOR` | -| `TR-OWN-DOWN-EASY` | Own-engine note-body gain on easier downward clips | `FAM-OWN-PITCH` | `pitchOrg -4` note mel `7.405 -> 5.560` with exact core pitch, but no generalization to `pitchTest -4` | No | Future downward-specific hybrid study | -| `TR-PHASE-LOCK-COHERENCE` | Peak-locked phase coherence around harmonic bodies | `FAM-PHASE-LOCK-PV` | `P1` only showed a selective `pitchTestOrg +4` engagement, but the resulting note/entry regression was not worth harvesting as a kept trait | No | Future phase-coherent DSP family only if a materially different implementation is defined | -| `TR-HPSS-MEDIAN-TRANSIENT` | Median-filter transient preservation without body invasion | `FAM-HPSS-MEDIAN` | `Hm1` engaged on `pitchOrg +4`, but the shell still dragged the note body off pitch (`-55.55` cents) and worsened note/entry mel, so there is no keepable transient-preserve trait yet | No | Future HPSS median family only if a materially different shell is defined | -| `TR-SF-HARMONIC-ENVELOPE` | Harmonic-only spectral-envelope preservation on top of a stronger shell | `FAM-HPSS-MEDIAN-SF` | Pending family implementation | No | Future HPSS median + source-filter family | -| `TR-ML-RESTORE` | Offline restoration of residual pitch-shift artifacts | `FAM-ML-RESTORATION` | `M1` proxy benchmark proved the harness can improve the easier `pitchOrg +4` case, but it materially harmed `pitchTestOrg +4`, so there is no keepable restore trait yet | No | Future offline restoration benchmark | -| `TR-TRANSITION-HQ` | Transition-focused shell/core/residual overlay | `FAM-TRANSITION-HQ` | `M1` engaged cleanly and exposed useful diagnostics, but it worsened both `+4` truth cases and produced no keepable transition trait | No | Future engine-v2 work only if a materially different transition model is defined | - -## Active Queue -1. Stronger ML / external benchmark only - - keep the offline benchmark harness and diagnostics - - do not reopen local ML restoration on another proxy or tuning-only pass - - only continue once a materially stronger restorer or outside/licensed benchmark path is available -2. `FAM-ENGINE-V2` - - keep the branch frozen as comparison evidence and for user audition only - - do not spend main tuning budget here unless a materially stronger decomposition/transition model is defined first -3. `FAM-ENGINE-V3` only after a stronger decomposition design exists - - do not continue from the current `V3-1` probe - - require a materially stronger transient/core/residual decomposition and transition-pair ownership design first -4. `FAM-PVDR` reopen only if a materially different stable implementation is ready - - do not reuse the rejected resample-plus-phase-lock overlay - - only reopen with a more stable phase-gradient or otherwise fundamentally different phase-coherent implementation -5. External benchmark decision - - trained ML restoration program - - or outside/licensed renderer benchmark - -## Stop List -- Do not revisit `FAM-SCALAR` without a new structural reason. -- Do not revisit `FAM-BODY-A`, `FAM-BODY-B`, `FAM-BODY-C`, or `FAM-BODY-D` as standalone handoff architectures. -- Do not revisit `FAM-CONT-E1` or `FAM-CONT-E2`. -- Do not revisit `FAM-ISLAND-F` or `FAM-ISLAND-G` as-is. -- Do not revisit `FAM-V2-HSR` as implemented on 2026-04-15; that residual-layer shell has been tested and removed. -- Do not revisit `FAM-V2-SYNTH-CORE` as implemented on 2026-04-15; that directly synthesized island-core pass has been tested and removed. -- Do not revisit `FAM-HPSS-SHELL` as implemented on 2026-04-15; it improved the easy-note onset score but collapsed note/body quality and never generalized to `pitchTest +4`. -- Do not revisit `FAM-WSOLA-SEAM` as implemented on 2026-04-15; the only engaged case (`pitchOrg +4`) got worse, and the other canonical truth cases stayed identical to `FAM-ADAPTIVE-SELECTOR`. -- Do not revisit `FAM-PHASE-LOCK-PV` as implemented on 2026-04-15; the only engaged case (`pitchTestOrg +4`) still lost on note mel and entry despite selective boundary alignment. -- Do not revisit `FAM-HPSS-MEDIAN` as implemented on 2026-04-15; it finally used a real median-filter shell, but the only engaged case (`pitchOrg +4`) still regressed on body pitch, note mel, and entry. -- Do not revisit `FAM-PVDR` as implemented on 2026-04-15; the first resample-plus-phase-lock overlay failed catastrophically on `pitchTestOrg +4`. -- Do not revisit `FAM-TRANSITION-HQ` as implemented on 2026-04-15; the transition overlay engaged on both `+4` truth cases but worsened both, especially `pitchTestOrg +4`. -- Do not revisit `FAM-ADVANCED`, `FAM-FORMANT-ONLY`, or `FAM-PITCH-PLUS-FORMANT` as standalone truth-case candidates. -- Do not revisit `FAM-CORE-PSOLA`, `FAM-CORE-MODEL`, or `FAM-OWN-PITCH` as standalone editors; harvest only explicitly proven traits. -- Do not retry pure WORLD end-to-end rendering. -- Do not spend more tuning cycles on local Stage B / blend-shape patching in the current Signalsmith-centered handoff family. -- Do not keep rejected renderer families alive just because one trait was useful; harvest the trait and remove the dead path. diff --git a/docs/pitch_renderer_research_notes.md b/docs/pitch_renderer_research_notes.md index 1d83073..ce255f0 100644 --- a/docs/pitch_renderer_research_notes.md +++ b/docs/pitch_renderer_research_notes.md @@ -1,6 +1,7 @@ # Pitch Renderer Research Notes -Date: 2026-04-15 +- Started: 2026-04-15 +- Last consolidated: 2026-04-28 Purpose: - capture primary-source research before more renderer implementation @@ -83,6 +84,21 @@ Purpose: - formant/timbre drift on note changes - weak generalization from `pitchOrg` to `pitchTestOrg` +## Consolidated architecture evidence + +The bounded root-cause and feasibility probes support three durable conclusions. Their numeric scores are historical, `diagnostic_only` evidence; they are useful for comparing the tested branches, but do not prove perceived quality or the absence of audible artifacts. + +1. Transition ownership is architectural, not another crossfade-tuning problem. + - The April 17 hard-case adaptive run still reported a `18.917 ms` maximum boundary/transient timing error, transient means of `1.598` at entry and `6.543` at exit, and `0.071` mean formant drift. + - The frozen `pitch_only_engine_v2_program` challenger reported `7.017` entry mel distance and `4.000` onset artifact even while its formant-drift diagnostic was `0.062`. + - The resulting design requirement is to own entry and exit as one transition pair, distinguish transient shell and first voiced cycles from the stable voiced core, and keep pitch-carrier ownership separate from timbre repair. +2. The first clean-sheet decomposition probe was not a viable `engine-v3` starting point. + - Its shell/core/residual heuristic scored `0.511` on `pitchOrg_plus4` and `0.505` on `pitchTest_plus4`; both runs returned the probe's `stop` verdict. + - This rejects that specific heuristic, not every future redesign. Reopen a clean-sheet branch only after defining a materially stronger decomposition and transition-pair ownership model, then proving it in a bounded feasibility probe. +3. Restoration remains a valid research direction, but the local proxy path is closed. + - The April 17 environment had a working CUDA runtime but no suitable note-local `voicefixer` or `demucs` restorer; `audio_separator` was available but did not meet the task, and `proxy_ml_restore_v1` had already been rejected. + - Do not treat package availability from that snapshot as a permanent dependency fact. The durable gate is that `FAM-ML-RESTORATION` may resume only with a materially stronger external, licensed, or research-backed restorer and a representative benchmark—not another local proxy. + ## Primary-source findings ### 1. Median-filter HPSS is real, but it is a separation tool, not a complete vocal pitch-editor solution @@ -168,13 +184,15 @@ Source: - heuristic HPSS shell - scalar median HPSS shell - lightweight boundary-aligned phase-lock variant +- the frozen `pitch_only_engine_v2_program` challenger +- the first `engine-v3` shell/core/residual decomposition heuristic ### Still genuinely viable - a true research-grade phase-vocoder family: - identity / peak phase locking - or phase-gradient / RTPGHI-style integration -- an offline restoration benchmark on top of the best current renderer -- DDSP-style engine-v2 work if we accept a larger revamp +- an offline restoration benchmark on top of the best current renderer, once a genuinely stronger restorer is available +- DDSP-style or other conditioned source/filter redesign work if we accept a larger revamp and first pass a bounded decomposition/transition feasibility gate ### Lower priority or support only - WORLD as decomposition support only @@ -182,15 +200,18 @@ Source: - more seam-only local overlap tweaks ## Recommended next queue -1. `FAM-PVDR` +1. Keep `pitch_only_adaptive_selector` as the production editor while alternatives remain benchmark-only. +2. `FAM-PVDR` - proper phase-coherent phase-vocoder family - not another boundary-alignment patch - long/stable voiced regions first -2. `FAM-ML-RESTORATION` - - offline benchmark on top of `pitch_only_adaptive_selector` +3. `FAM-ML-RESTORATION`, conditional on acquiring a genuinely stronger restorer + - benchmark offline on top of `pitch_only_adaptive_selector` - treat artifacts as restoration rather than trying to make the base shifter perfect -3. Broader validation sweep on `FAM-ADAPTIVE-SELECTOR` + - do not reopen with `proxy_ml_restore_v1` or a generic source separator +4. Broader validation sweep on `FAM-ADAPTIVE-SELECTOR` - keep the current best editor stable while new work stays benchmark-only +5. A clean-sheet engine only after a stronger decomposition/transition-pair design passes a small feasibility probe. ## Why this matters - The current repo pattern is clear: diff --git a/docs/pitch_root_cause_research_20260417.md b/docs/pitch_root_cause_research_20260417.md deleted file mode 100644 index 23ca2fa..0000000 --- a/docs/pitch_root_cause_research_20260417.md +++ /dev/null @@ -1,109 +0,0 @@ -# Pitch Root-Cause Research 2026-04-17 - -## Summary -This pass was meant to answer three bounded questions: - -1. Why are the two remaining product issues still happening? -2. Is there a fast local ML/external-style restoration path available now? -3. Is a clean-sheet `engine-v3` DSP reset credible enough to continue immediately? - -The answers are: - -- The failures are primarily architectural, not just tuning-related. -- No materially stronger local ML restorer is available in this environment right now. -- The first `engine-v3` feasibility probe does not justify immediate build-out. - -Baseline remains: - -- kept renderer: `pitch_only_adaptive_selector` -- frozen comparison-only challenger: `pitch_only_engine_v2_program` - -## Top Causes -| Rank | Cause | Confidence | Evidence | Likely fixes | -| --- | --- | --- | --- | --- | -| 1 | Transition ownership and boundary timing drift | high | Hard-case adaptive boundary timing stays high even after correction (`18.92 ms` boundary max; `18.92 ms` transient max), and frozen engine-v2 still collapses on hard-case entry (`entry mel 7.017`). | Own entry and exit as a transition pair instead of as per-note local patches; move shell/core alignment earlier at note entry instead of relying on late correction; only benchmark architectures that separate transient shell from voiced core before pitch rendering | -| 2 | Mixed transient and first-voiced-cycle content is still being handled by one renderer family | medium | Adaptive correction improves some easy windows but hard-case transient metrics stay elevated (`pitchTest` transient entry/exit max `1.614 / 6.723`), and engine-v2 still fails with transient bypass enabled. | Use a true shell/core/residual decomposition before note-change rendering; treat the first voiced cycles as their own ownership zone; prefer learned restoration or a clean-sheet transition renderer over more local handoff tuning | -| 3 | Current formant preservation is too weak and too local to survive hard transitions | high | Formant drift remains in the adaptive path even after correction (`pitchOrg` formant mean `0.364`; `pitchTest` formant mean `0.071`), while engine-v2 keeps spectral envelope correction on but still loses the hard case (`formant drift 0.062`, `entry mel 7.017`). | Use an explicitly conditioned source-filter or learned restoration stage instead of a light cepstral patch; keep pitch exactness in the carrier and repair timbre only inside the affected transition region; only continue DSP work if the new architecture bakes formant handling into the core renderer | - -## Key Evidence -- Original vs reference hard case: - - boundary timing `39.833 ms` - - formant drift `0.528` -- Adaptive best hard-case transient means: - - entry `1.598` - - exit `6.543` - - timing max `18.917 ms` -- Adaptive best hard-case formant mean drift: - - `0.071` -- Frozen engine-v2 hard-case run: - - entry mel `7.017` - - onset artifact `4.000` - - formant drift `0.062` - -Primary artifact files: - -- root-cause run: - - `D:\test projects\os tests\runs\20260417_012542_pitch_root_cause_research\pitch_root_cause_research.md` - - `D:\test projects\os tests\runs\20260417_012542_pitch_root_cause_research\pitch_root_cause_research.json` -- ML benchmark: - - `D:\test projects\os tests\runs\20260417_003355_pitch_ml_benchmark\pitch_ml_benchmark_summary.md` -- engine-v3 feasibility: - - `D:\test projects\os tests\runs\20260417_003355_engine_v3_feasibility\pitch_engine_v3_feasibility_summary.md` - -## ML Benchmark Verdict -Local ML restoration is blocked for now. - -Environment result: - -- verdict: `blocked_no_stronger_restorer` -- runtime ready: `true` -- selected backend: `cuda` - -Candidate check: - -- `voicefixer`: not installed -- `demucs`: not installed -- `audio_separator`: available, but not note-local restoration -- `proxy_ml_restore_v1`: explicitly excluded and already rejected - -Meaning: - -- do not reopen `FAM-ML-RESTORATION` locally on another proxy -- the next ML step is to source a genuinely stronger restorer or benchmark an outside/licensed path - -## Engine-v3 Feasibility Verdict -The first `V3-1` decomposition probe says `stop`, not `continue`. - -Results: - -| Case | Decomposition score | Verdict | -| --- | ---: | --- | -| `pitchOrg_plus4` | `0.511` | `stop` | -| `pitchTest_plus4` | `0.505` | `stop` | - -Interpretation: - -- the first shell/core/residual split heuristic is not yet stronger than the current adaptive carrier -- there is no evidence yet that an immediate `engine-v3` build-out would avoid repeating the `engine-v2` failure pattern - -Meaning: - -- do not start a long `engine-v3` branch from this decomposition probe alone -- only reopen `engine-v3` if we define a materially stronger decomposition and transition-pair ownership model first - -## Decision -Current best action order is: - -1. Keep `pitch_only_adaptive_selector` as the working editor. -2. Stop local ML retries until a stronger restorer is actually available. -3. Do not expand `engine-v3` from the current feasibility probe. -4. If we want the fastest chance of a real audible improvement, benchmark an external/licensed or materially stronger ML restorer next. -5. If we want a new in-house DSP engine later, begin with a better decomposition/transition design doc first, not another immediate renderer branch. - -## What This Research Changed -This pass closes the main uncertainty from the earlier tuning program: - -- the problem is not mainly unresolved because we missed one more STFT or crossfade tweak -- the problem remains because the current family does not own the transition pair strongly enough, still mixes transient and first voiced cycles too early, and only preserves formants with a weak local correction - -That means the next step should be bounded and evidence-led, not another open-ended renderer tuning loop. diff --git a/docs/pr_footprint_cleanup_20260501.md b/docs/pr_footprint_cleanup_20260501.md deleted file mode 100644 index 394efeb..0000000 --- a/docs/pr_footprint_cleanup_20260501.md +++ /dev/null @@ -1,24 +0,0 @@ -# PR Footprint Cleanup - 2026-05-01 - -## Keep - -- Source, frontend, docs, tools, and test fixture files that are part of active feature work or regression coverage. -- AI runtime installer/configuration files such as `tools/install_ai_tools.py`, `tools/prepare-ai-runtime.ps1`, runtime install-plan JSON files, `tools/openstudio_ace_runner.py`, and the lightweight `tools/openstudio_ace_backend` bridge sources. -- Pitch regression scripts and fixtures needed to reproduce the pitch-renderer work. - -## Ignore Or Remove - -- `tmp_pitch_runs/` generated pitch regression runs. Pre-cleanup status showed 2951 untracked files in this directory. -- `tools/openstudio_ace_backend/vendor_runtime/` downloaded/generated ACE runtime payloads. Pre-cleanup status showed 429 untracked files in this directory. The installer/probe code reconstructs this runtime from the pinned runtime plan and cache; it should not be committed. -- Generated pitch regression WAV/PNG/JSONL reports under `tests/fixtures/pitch-regression`. -- Local debug capture output under `pitch_debug_captures/`. - -## Needs Review - -- Untracked source/docs/frontend/test files outside the generated buckets are not deleted here. They appear to be intentional feature or regression work and should be reviewed by feature owner before commit. -- Tracked modified files remain untouched by this cleanup pass. - -## Actions - -- Added `.gitignore` coverage for generated pitch runs, pitch debug captures, generated report media, and the ACE vendor runtime payload. -- Removed generated local runtime/run folders from the working tree after verifying they were inside the repository root. diff --git a/docs/qwen-daw-assistant-turboquant-plan.md b/docs/qwen-daw-assistant-turboquant-plan.md deleted file mode 100644 index f5999d1..0000000 --- a/docs/qwen-daw-assistant-turboquant-plan.md +++ /dev/null @@ -1,584 +0,0 @@ -# Qwen DAW Assistant, TurboQuant, GGUF, and ACE Step Plan - -Date: 2026-04-27 - -Status: planning document only. Do not treat this file as implementation. - -## Goal - -Build a local-only Qwen assistant for Studio13 that can understand plain English, inspect project/audio context, plan DAW/plugin actions, explain the impact of those actions, and execute only after user confirmation. - -The assistant must also control the existing ACE Step 1.5 XL Turbo music-generation integration. - -The installer must check hardware first, download only one supported model/runtime profile, verify that it actually runs, and fail clearly if unsupported. No runtime fallback and no downloading a larger model that cannot run on the machine. - -## Key Decision - -Use Qwen-only audio/music-capable models. - -Do not use Gemma. - -Do not use text-only Qwen models as the main assistant, even if they are easier to run, because the requirement is audio/music understanding. - -ACE Step's internal `qwen_4b_ace15.safetensors` model is not the Studio13 assistant brain. It is part of the ACE generation stack and should stay there. - -## What TurboQuant Actually Gives Us - -TurboQuant is KV-cache compression for vLLM inference. It is not a model-weight quantizer. - -That means: - -- TurboQuant helps with longer context after the base model has loaded. -- TurboQuant can reduce KV memory pressure for large project/audio summaries. -- TurboQuant does not make an oversized model's weights fit into VRAM by itself. -- TurboQuant applies to the vLLM path, not the GGUF/llama.cpp path. -- For quality-sensitive audio/music planning, prefer 4-bit values where possible. TurboQuant's own README identifies low-bit value quantization as the main quality bottleneck. - -TurboQuant's published hardware tests are not audio-model tests: - -| TurboQuant-tested model | Hardware | Relevance to Studio13 | -| --- | --- | --- | -| `Qwen3.5-27B-AWQ` | 1x RTX 5090 32GB | Useful proof of vLLM KV compression, but not an audio/music Omni assistant target. | -| `Qwen3.5-35B-A3B` | 8x RTX 3090 24GB | Useful MoE/KV reference, but not a practical single-workstation Studio13 target. | - -Studio13 should use TurboQuant as an optimization layer only after a Qwen audio model has passed a real load test. - -## Runtime Families - -The installer should probe both runtime families before downloading a model, then select exactly one verified profile. - -### Primary: vLLM/vLLM-Omni + AWQ + TurboQuant - -Use when WSL2 CUDA validates cleanly. - -Includes: - -- WSL2/Linux CUDA runtime. -- vLLM or vLLM-Omni. -- AWQ or AWQ-Marlin where supported. -- TurboQuant KV-cache compression. -- Triton kernels from vLLM/TurboQuant. -- FlashAttention or vLLM's optimized attention backend. -- PagedAttention. -- Fixed context, batch, and audio-window limits based on VRAM. - -Preferred 16GB VRAM candidate: - -- `Qwen/Qwen2.5-Omni-7B-AWQ` - -Why: - -- It supports text, image, audio, and video input. -- Its model card explicitly targets lower-VRAM GPUs. -- Its official table lists substantially lower memory than BF16: 11.77GB for 15s video, 17.84GB for 30s video, 30.31GB for 60s video. -- For Studio13, we should use audio-only input and `return_audio=false`, so the practical audio-only load can be lower than the video table, but it still must be verified locally. - -### Secondary: llama.cpp + GGUF + mtmd/mmproj - -Use only if the exact GGUF model and audio path verify successfully. - -Includes: - -- llama.cpp CUDA build. -- GGUF language/model file. -- matching multimodal projector or mtmd-compatible assets when required. -- GPU layer offload. -- llama.cpp flash attention where supported. -- audio-only verification prompt. - -Important: - -- TurboQuant does not apply to GGUF. -- GGUF model weight quantization and TurboQuant KV compression are separate technologies. -- llama.cpp multimodal/audio support is actively evolving, so GGUF profiles must be marked candidate-only until Studio13's installer proves that the exact model can accept audio and return reliable text/tool-plan output. - -## Qwen Model Map: Small to Large - -Only models with audio/music understanding are allowed in the assistant model map. - -The installer should maintain a strict profile manifest. A model is "loadable" only if both the hardware prefilter and the real startup test pass. - -| Tier | Model | Role | Audio/music support | Runtime candidates | Minimum install rule | -| --- | --- | --- | --- | --- | --- | -| Small | `Qwen/Qwen2.5-Omni-3B` | Lowest-footprint direct audio assistant | Yes. Official card reports music/audio reasoning benchmarks. | vLLM-Omni, Transformers, GGUF candidate | Install only after a real 10s audio load test. Prefer this when 7B-AWQ fails on 16GB VRAM. | -| Recommended | `Qwen/Qwen2.5-Omni-7B-AWQ` | Main consumer-GPU assistant | Yes. Better than 3B on most audio/text tasks and has low-VRAM AWQ profile. | vLLM-Omni/Transformers AWQ | Primary target for 32GB RAM / 16GB VRAM. Use 10-15s audio batches by default. | -| Medium | `Qwen/Qwen2.5-Omni-7B` BF16 | Higher-quality 7B baseline | Yes | vLLM-Omni/Transformers | Reject on 16GB VRAM. Require large-VRAM verification, roughly 48GB+ practical headroom. | -| Large analysis sidecar | `Qwen/Qwen3-Omni-30B-A3B-Captioner` | Detailed audio caption/context extractor | Yes, audio-focused text output | vLLM/Transformers, GGUF candidate | Not the main DAW control brain. Require high-VRAM verification or remote lab machine. | -| Large reasoning | `Qwen/Qwen3-Omni-30B-A3B-Thinking` | Best multimodal reasoning candidate | Yes | vLLM/Transformers, GGUF candidate | Reject on 16GB VRAM. Official BF16 table is 68.74GB minimum for 15s video. | -| Largest | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | Full thinker/talker model | Yes | vLLM/Transformers, GGUF candidate | Reject on 16GB VRAM. Official BF16 table is 78.85GB minimum for 15s video. Disable talker when text output only. | - -Legacy candidate: - -- `Qwen/Qwen2-Audio-7B-Instruct` can understand audio, but Qwen2.5-Omni should be preferred because it has stronger documented multimodal/audio/music performance and better current runtime paths. - -## Hardware Selection Rules - -These are installer prefilter rules. The final authority is a real local model-load and inference test. - -| Hardware | Selected profile | -| --- | --- | -| 16GB VRAM, 32GB RAM, WSL2 CUDA passes | `Qwen/Qwen2.5-Omni-7B-AWQ` through vLLM/vLLM-Omni, TurboQuant enabled after load. | -| 16GB VRAM, vLLM path fails but llama.cpp audio GGUF path verifies | `Qwen2.5-Omni-3B-GGUF` first; `Qwen2.5-Omni-7B-GGUF` only if audio load test passes with headroom. | -| 24GB VRAM | `Qwen/Qwen2.5-Omni-7B-AWQ` with longer audio windows, or verified `Qwen2.5-Omni-7B-GGUF`. | -| 48GB VRAM | `Qwen/Qwen2.5-Omni-7B` BF16 or AWQ with longer batches. | -| 80GB+ VRAM | Consider `Qwen3-Omni-30B-A3B-Thinking`. | -| 96GB+ VRAM or multi-GPU | Consider `Qwen3-Omni-30B-A3B-Instruct`. | -| No verified profile | Do not download a model. Mark assistant unsupported. | - -For the current target class of 32GB RAM / 16GB VRAM, the realistic plan is: - -1. Try `Qwen/Qwen2.5-Omni-7B-AWQ` with audio-only inference, `return_audio=false`, 10s default audio windows. -2. If it cannot be loaded during pre-download verification planning, try the 3B Omni profile. -3. If neither profile can be proven loadable, do not install the assistant model. - -## Installer Requirements - -The existing AI runtime installer should gain a separate "assistant runtime" section while keeping ACE Step installation intact. - -Probe before download: - -- Windows version. -- WSL2 availability. -- NVIDIA driver and CUDA visibility inside WSL2. -- GPU model, VRAM, compute capability, and free VRAM. -- System RAM and free disk. -- Existing ACE Step runtime status. -- Whether vLLM/vLLM-Omni can start. -- Whether TurboQuant can import and attach its vLLM integration. -- Whether llama.cpp CUDA build can run an mtmd/audio smoke test. - -Profile manifest fields: - -- `profileId` -- `modelRepo` -- `modelRevision` -- `runtimeFamily` -- `quantization` -- `requiresAudioInput` -- `requiresMmproj` -- `minRamGb` -- `minVramGb` -- `minFreeDiskGb` -- `defaultAudioWindowSeconds` -- `maxVerifiedAudioWindowSeconds` -- `contextTokens` -- `gpuOffloadPolicy` -- `turboQuantEnabled` -- `flashAttentionEnabled` -- `tritonRequired` -- `startupTestPrompt` -- `audioSmokeTestFile` -- `sha256` or pinned file checksums where possible - -Verification after download: - -1. Load the model. -2. Load multimodal/audio projector if required. -3. Run a 10s audio/music understanding test. -4. Run a strict JSON action-plan test. -5. Validate against Studio13's assistant action schema. -6. For vLLM path, verify TurboQuant integration and the attention backend. -7. For GGUF path, verify llama.cpp audio input through its supported local API/CLI. -8. Record the verified profile in a runtime status file. - -No fallback policy: - -- The installer may probe multiple profiles before download. -- The installer must download only the selected profile. -- After download, if verification fails, mark assistant unsupported. -- Do not silently install a smaller model after a failed selected install. -- User can explicitly rerun setup to select a different profile. - -## Audio Context Strategy - -Do not feed all multitrack audio directly into the LLM. - -Use deterministic project/audio analysis first, then send compact structured context plus selected raw audio windows only when needed. - -Per clip context: - -- file path/id -- duration -- sample rate -- channels -- clip gain -- fades -- offset/trim -- loudness -- peak/true peak -- silence regions -- transient density -- pitch/key estimate where relevant -- tempo hints -- spectral tilt -- stereo width - -Per track context: - -- track name/type guess -- clips -- volume/pan/mute/solo/arm -- routing/sends -- FX chain and plugin params -- automation summary -- loudness and peak range -- frequency balance -- role guess such as vocal, drums, bass, guitar, keys, FX, bus, master - -Master context: - -- integrated LUFS -- short-term LUFS -- true peak -- crest factor -- clipping risk -- spectral balance -- stereo width -- phase/correlation -- dominant problem regions - -Batching rules: - -| Situation | Audio window policy | -| --- | --- | -| Selected clip edit | Send selected clip summary plus 5-10s raw audio around selection. | -| Track-level mix command | Send full track summary plus 10s representative windows. | -| Master mix command on small project | Send all track summaries plus representative 10s windows from loud/active sections. | -| Master mix command on large project | Batch by buses/stems first, then request track-level details only for problematic groups. | -| Many tracks or long project | Use section summaries first. Raw audio only for selected sections. | -| Micro timing/glitch tasks | Use 1s windows around transient/problem locations. | -| Arrangement/structure tasks | Use 30-100s symbolic/summary windows, not raw audio by default. | - -Default for 16GB VRAM: - -- 10s audio windows. -- No video input. -- Text output only. -- `return_audio=false`. -- TurboQuant enabled only on the vLLM path after model load. - -## Assistant Action Surface - -All actions must be typed, validated, reversible where possible, and confirmation-gated. - -The assistant should not directly mutate state. It should produce an action plan that Studio13 executes through existing store/bridge paths. - -Every clip/track/project data mutation must use undo-aware command paths. - -### Transport and Timeline - -- Play, stop, pause, record. -- Seek to time/bar/marker. -- Set loop region. -- Toggle metronome/count-in. -- Set tempo/time signature. -- Add, remove, rename, and navigate markers/regions. -- Set snap/grid/zoom/tool mode. - -### Tracks and Mixer - -- Add, duplicate, remove, rename, color, reorder tracks. -- Create folder tracks, buses, VCAs, groups. -- Set volume, pan, width, phase, mute, solo, arm, monitoring. -- Set input/output routing. -- Add/remove/adjust sends. -- Create mix snapshots. -- Compare or restore mixer snapshots. - -### Clips and Editing - -- Import audio/MIDI. -- Add generated audio clips. -- Move, split, trim, resize, duplicate, delete clips. -- Set clip gain, mute, color, lock, group, reverse. -- Apply fades/crossfades. -- Quantize, nudge, align, slip edit. -- Dynamic split by transients. -- Normalize or adjust gain. -- Render/bounce selected clips. - -### Plugins and FX Chains - -- Insert built-in FX or scanned plugins. -- Remove, bypass, enable, reorder plugins. -- Read plugin parameters. -- Set plugin parameters. -- Load presets where available. -- Create common chains, such as vocal cleanup, bass control, drum bus glue, mastering limiter. -- Explain expected sonic impact before applying. - -### Automation - -- Create/show/hide automation lanes. -- Add, move, delete automation points. -- Set automation mode. -- Draw fades, ramps, ducking, pan moves, filter sweeps. -- Summarize automation conflicts. - -### Pitch, Stem, and Audio Analysis - -- Analyze pitch contour. -- Apply pitch correction. -- Extract MIDI where supported. -- Separate stems. -- Detect tempo/key. -- Detect transients/silence. -- Analyze loudness/spectrum/stereo. -- Suggest corrective chains based on analysis. - -### Render and Export - -- Configure render format. -- Render master. -- Render selected tracks/stems only when backend support exists. -- Normalize/tail options where supported. -- Explain unsupported render options rather than pretending they work. - -### ACE Step 1.5 XL Turbo - -The assistant must be able to control ACE Step through Studio13's existing AI generation pipeline. - -Actions: - -- `ai.getRuntimeStatus` -- `ai.openSetup` -- `ai.createAITrack` -- `ai.setWorkflow` -- `ai.setGenerationParams` -- `ai.generateMusic` -- `ai.cancelGeneration` -- `ai.pollGeneration` -- `ai.insertGeneratedClip` - -ACE workflow params to expose: - -- `workflowId` -- `prompt` -- `lyrics` -- `seed` -- `bpm` -- `duration` -- `timesignature` -- `language` -- `keyscale` -- `generate_audio_codes` -- `inferenceSteps` -- `cfg_scale` -- `guidance_scale` -- `shift` -- `temperature` -- `top_p` -- `top_k` -- `min_p` - -The assistant should not call `tools/generate_music.py` directly. It should use the app's existing NativeBridge/store flow so progress, cancellation, UI status, and generated clip insertion stay consistent. - -If the user requests continuation from an existing clip, the assistant should only use ACE continuation if that workflow is implemented and marked available. Otherwise it should say continuation is unavailable and offer a prompt/style-based generation using extracted clip context. - -## Example Action Chains - -### "Make the vocal clearer" - -Plan: - -1. Analyze selected vocal track loudness, spectral balance, silence, and FX chain. -2. Add or adjust high-pass EQ. -3. Add gentle compression. -4. Add de-esser if sibilance is detected. -5. Raise presence band only if masking is detected. -6. Lower competing instruments or add sidechain/dynamic EQ if needed. -7. Preview and keep changes undoable. - -Impact: - -- Improves intelligibility. -- May make vocal brighter or more forward. -- Can increase harshness if overdone, so cap boosts conservatively. - -### "Make the master louder without clipping" - -Plan: - -1. Analyze master LUFS, true peak, crest factor, clipping risk. -2. Identify tracks/buses causing peak spikes. -3. Lower or compress those tracks first. -4. Add/adjust master bus compression only if needed. -5. Add limiter ceiling, e.g. -1.0 dBTP. -6. Increase gain to target loudness. -7. Re-analyze and show before/after. - -Impact: - -- Raises perceived loudness. -- May reduce dynamics. -- Safer than only pushing a limiter because it fixes source peaks first. - -### "Generate a dark synthwave intro" - -Plan: - -1. Read project tempo/key if present. -2. Create or select an AI track. -3. Set ACE Step workflow to `text-to-music`. -4. Translate request into ACE params: prompt, BPM, key, duration, seed. -5. Confirm with the user. -6. Start ACE generation. -7. Poll progress. -8. Insert generated clip. -9. Name/color/route track. - -Impact: - -- Adds a new generated audio clip. -- Does not modify existing audio. -- Can be regenerated with a seed change. - -### "Use this clip as context and create drums that fit" - -Plan: - -1. Analyze selected clip tempo/key/energy/transients. -2. Summarize style and rhythmic feel. -3. Build an ACE prompt from the summary. -4. Generate a drum-focused clip on a new AI track. -5. Align clip start to selection. -6. Set gain conservatively and route to drum bus if present. - -Impact: - -- Creates a new generated drum layer. -- Existing clip remains untouched. -- Exact continuation depends on whether ACE continuation workflow is available. - -## UI Plan - -Add a collapsible chat bar on the right side of the DAW. - -States: - -- collapsed icon button -- expanded chat -- context selected -- thinking/planning -- awaiting confirmation -- executing -- success -- failed/unsupported - -The assistant response should show: - -- interpreted intent -- affected tracks/clips/plugins -- proposed action chain -- expected sonic impact -- risk/destructive status -- undo availability -- confirmation controls - -Execution policy: - -- Never auto-execute data-changing actions from plain text. -- Always ask for confirmation. -- Allow safe read-only analysis without confirmation. -- Let the user inspect the exact action chain before execution. - -## Feasibility Report - -Feasible, with the right constraints. - -Most realistic for 32GB RAM / 16GB VRAM: - -- `Qwen/Qwen2.5-Omni-7B-AWQ` -- vLLM/vLLM-Omni in WSL2 CUDA -- audio-only input -- `return_audio=false` -- 10s default raw audio windows -- project summaries for larger context -- TurboQuant for KV/context pressure after load - -Likely not feasible on 16GB VRAM: - -- Qwen3-Omni 30B BF16 variants. -- Long raw multitrack audio context in one prompt. -- Full raw master mix analysis for many tracks without batching. - -GGUF feasibility: - -- Useful as a secondary path. -- Must be verified per exact model because llama.cpp audio/multimodal support is still moving quickly. -- Do not assume a GGUF file is enough; audio input support and projector compatibility must pass a real smoke test. - -ACE Step feasibility: - -- Already integrated as a music-generation engine. -- Very useful as an assistant-controlled tool. -- Not suitable as the general DAW assistant model. -- The assistant should use ACE for generation, not for reasoning/planning. - -## Implementation Phases - -### Phase 1: Documentation and manifests - -- Create assistant model profile manifest. -- Add action schema draft. -- Define installer probe outputs. -- Define verification prompts and audio smoke-test assets. - -### Phase 2: Runtime probe - -- Extend AI runtime probe for assistant runtime. -- Add WSL2 CUDA checks. -- Add vLLM/vLLM-Omni import checks. -- Add TurboQuant import/hook checks. -- Add llama.cpp CUDA/mtmd checks. - -### Phase 3: Installer selection - -- Select exactly one model profile before download. -- Download only selected model. -- Run verification. -- Save verified runtime status. -- Fail clearly if unsupported. - -### Phase 4: Assistant service - -- Local HTTP service wrapper around selected runtime. -- Strict JSON output mode. -- Tool/action schema validation. -- Project/audio context assembler. -- Plan generation and confirmation flow. - -### Phase 5: DAW integration - -- Collapsible right chat bar. -- Action preview UI. -- Confirmation and execution state. -- Undo-aware action execution. -- ACE Step action control. - -### Phase 6: Audio intelligence - -- Clip/track/master feature summaries. -- Representative audio window selector. -- Batch planner for large projects. -- Before/after analysis for mix actions. - -## Acceptance Criteria - -- On unsupported hardware, no assistant model is downloaded. -- On supported hardware, exactly one assistant model profile is downloaded. -- Startup verification proves audio input, JSON planning, and schema validation. -- The assistant can explain and confirm a DAW action chain before running. -- The assistant can control ACE Step generation through Studio13's existing AI generation flow. -- Master mix requests use track/bus analysis and batched context instead of dumping all audio into the LLM. -- All data-changing actions remain undoable where Studio13 supports undo. - -## Sources - -- TurboQuant: https://github.com/0xSero/turboquant -- vLLM-Omni supported models: https://docs.vllm.ai/projects/vllm-omni/en/latest/models/supported_models/ -- Qwen2.5-Omni-3B: https://huggingface.co/Qwen/Qwen2.5-Omni-3B -- Qwen2.5-Omni-7B-AWQ: https://huggingface.co/Qwen/Qwen2.5-Omni-7B-AWQ -- Qwen3-Omni-30B-A3B-Instruct: https://huggingface.co/Qwen/Qwen3-Omni-30B-A3B-Instruct -- llama.cpp multimodal/mtmd notes: https://github.com/ggml-org/llama.cpp/blob/master/tools/mtmd/README.md - diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 2475b46..1b599ff 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -35,14 +35,14 @@ Use this flow instead: - If runtime inputs did not change, keep `OPENSTUDIO_AI_RUNTIME_RELEASE_TAG` and `OPENSTUDIO_AI_RUNTIME_VERSION` pinned to the latest known-good runtime release. - If runtime inputs changed, publish the runtime first with `.github/workflows/ai-runtime-release.yml`, then update those variables to the new runtime release tag/version. 6. Push a version tag like `v0.0.2`. -7. Let `.github/workflows/release.yml` build Windows and macOS, reuse the pinned AI runtime release, publish the GitHub Release, attach the fixed-name assets, and then trigger the website repo so it can publish the public metadata and redirects. +7. Let `.github/workflows/release.yml` build Windows, macOS, and Linux, reuse the pinned AI runtime release, publish the GitHub Release, attach the release assets, and then trigger the website repo so it can publish the public metadata and redirects. 8. Verify the published direct-download URLs: - `https://github.com/<org>/<repo>/releases/latest/download/OpenStudio-Setup-x64.exe` - `https://github.com/<org>/<repo>/releases/latest/download/OpenStudio-macOS.dmg` - - `https://github.com/<org>/<repo>/releases/latest/download/OpenStudio-Linux.AppImage` + - `https://github.com/<org>/<repo>/releases/download/v<version>/OpenStudio-<version>-linux-x86_64.AppImage` - `https://github.com/<org>/<repo>/releases/download/<ai-runtime-tag>/OpenStudio-AI-Runtime-windows-base-x64.zip` - - `https://github.com/<org>/<repo>/releases/latest/download/OpenStudio-AI-Runtime-macos-arm64.zip` - - `https://github.com/<org>/<repo>/releases/download/<ai-runtime-tag>/OpenStudio-AI-Runtime-linux-x64.tar.gz` + - `https://github.com/<org>/<repo>/releases/download/<ai-runtime-tag>/OpenStudio-AI-Runtime-macos-arm64.zip` + - `https://github.com/<org>/<repo>/releases/download/<ai-runtime-tag>/OpenStudio-AI-Runtime-linux-cpu-x64.zip` 9. Verify the website repo finishes its deploy and the public metadata/redirect URLs on `openstudio.org.in` return JSON/XML/302 responses instead of the SPA HTML shell. The stable installer/runtime filenames are part of the public download contract. The website repo is now the only publisher of public metadata and redirects. @@ -59,11 +59,23 @@ If a release page shows only GitHub's default source archives, treat that as a f - Official Windows CI and release builds also provision the pinned Windows prerequisite installers used by the installer recovery flow. - To install the pinned optional ONNX Runtime package locally, run: `powershell -ExecutionPolicy Bypass -File tools/setup-onnxruntime.ps1` + The installer records and verifies the requested version, platform, archive + digest, import library, runtime DLL, headers, and redistributed notices before + reusing an existing local installation. - To install the pinned ASIO SDK locally, run: `powershell -ExecutionPolicy Bypass -File tools/setup-asio-sdk.ps1` - To install the pinned Windows prerequisite installers locally, run: `powershell -ExecutionPolicy Bypass -File tools/setup-windows-prereqs.ps1` -- To include ONNX Runtime in the Windows GitHub Actions build, set the repository variable `OPENSTUDIO_SETUP_ONNXRUNTIME=true`. +- Official Windows and Linux CI/release jobs provision the pinned ONNX Runtime + and validate its redistributed license notices. The current macOS release job + does not provision ONNX Runtime. +- Windows packages include one checksum-pinned, provenance-recorded FFmpeg + executable. macOS and Linux packages intentionally do not redistribute an + unpinned FFmpeg binary and use an optional system `ffmpeg` on `PATH`. +- Windows configuration and runtime validation fail if the pinned FFmpeg + executable or its required legal/provenance files are absent or altered. +- Linux release automation extracts the completed AppImage and reruns the + runtime-bundle contract against its packaged `usr/bin` payload. ## Dependency contract @@ -75,6 +87,42 @@ OpenStudio now follows the policy documented in `docs/runtime-dependency-contrac - Optional feature prerequisites, including Python for AI tools, must never block base app launch. - AI tools setup runs in the background and surfaces progress through the toolbar AI button plus a lightweight in-app popup. +## Release decision rules + +- Do not publish a Windows artifact containing the bundled `ffmpeg.exe` until + complete corresponding source for that exact static build (including its + linked libraries) is made available through the release distribution. The + packaged GPL text and provenance manifest are necessary notices, but are not + a substitute for corresponding source. +- Configure repository variables `OPENSTUDIO_FFMPEG_CORRESPONDING_SOURCE_URL` + and `OPENSTUDIO_FFMPEG_CORRESPONDING_SOURCE_SHA256` with an HTTPS archive and + digest for that exact complete source package. Release automation downloads, + verifies, checksums, and publishes it beside the Windows installer; missing + or mismatched configuration blocks the release. +- A passing `--startup-self-test` proves dependency and asset preflight, not a + rendered UI. The packaged main shell, detached Mixer, detached MIDI editor, + and built-in effect editor must each report `boot-ready` through close/reopen + cycles; native third-party editor lifecycle is checked separately. +- A Debug pass is not a Windows Release pass. The installed Release executable + is the browser/window approval artifact. +- Preserve the browser startup watchdog and the shared writable + `%APPDATA%\OpenStudio\WebView2UserData` configuration used by both WebView2 + preflight and construction. +- Upgrade JUCE only as an isolated dependency change. Require Debug and Release + compilation plus audio-device, plug-in-host, window-lifecycle, and packaging + gates. Realtime JUCE patches must fail closed if their expected upstream + source context changes. +- Treat code signing, notarization, and download reputation as separate + evidence. A signed Windows binary may still lack SmartScreen reputation, and + a valid macOS signature is not a substitute for the intended notarization and + Gatekeeper assessment. + +Before publication, complete the real-machine matrix in +`docs/release-smoke-checklist.md`: Windows 10 and 11 standard-user installs, +Apple Silicon plus Intel macOS (including the oldest supported macOS), actual +audio-device reconfiguration/record/render/sleep-wake checks, and available +VST3/CLAP/AU editor lifecycle checks while audio is active. + ## Local Windows release flow The local Windows RC gate is now the required no-surprises check before any push/tag for release: @@ -95,7 +143,7 @@ If you want one command for the full guarded Windows path, use: 1. Build the frontend: `cd frontend && npm ci && npm run build` 2. Install the ASIO SDK when you want parity with the official Windows release path: `powershell -ExecutionPolicy Bypass -File tools/setup-asio-sdk.ps1` -3. Optional: install ONNX Runtime for polyphonic pitch detection: `powershell -ExecutionPolicy Bypass -File tools/setup-onnxruntime.ps1` +3. Install ONNX Runtime for parity with the official Windows release and polyphonic pitch detection: `powershell -ExecutionPolicy Bypass -File tools/setup-onnxruntime.ps1` 4. Build the app in a clean release directory: `cmake -S . -B build-release-windows -A x64 "-DOPENSTUDIO_APP_VERSION=1.0.0" "-DJUCE_ASIOSDK_PATH=thirdparty/asio" "-DOPENSTUDIO_REQUIRE_ASIO=ON" "-DOPENSTUDIO_ENABLE_EXTERNAL_PYTHON_AI_FALLBACK=OFF" -DFETCHCONTENT_UPDATES_DISCONNECTED=ON` 5. Build the release target: `cmake --build build-release-windows --config Release --target OpenStudio` 6. Validate the runtime bundle: `./tools/validate-runtime-bundle.ps1 -Platform windows -BundlePath build-release-windows/OpenStudio_artefacts/Release -ExpectedVersion 1.0.0 -EnforceLeanBundle` @@ -124,14 +172,14 @@ If you want one command for the guarded macOS path, use: 3. Validate the app bundle: `./tools/validate-runtime-bundle.ps1 -Platform macos -BundlePath build-release-macos/<path-to-OpenStudio.app> -ExpectedVersion 1.0.0 -EnforceLeanBundle` 4. Package the DMG: `./tools/package-macos-release.sh build-release-macos/<path-to-OpenStudio.app> 1.0.0` - If `MACOS_CODESIGN_IDENTITY` is set, the script verifies both the app bundle and DMG with `codesign` and `spctl`. If notarization credentials are present, it also staples and validates the notarized DMG. - For the zero-cost v1 path, leave those signing variables unset and ship the unsigned DMG with manual Gatekeeper override instructions on the download page. + If `MACOS_CODESIGN_IDENTITY` is set, the script verifies both the app bundle and DMG with `codesign`. If notarization credentials are present, it also staples and validates the notarized DMG and requires Gatekeeper (`spctl`) acceptance. + For the zero-cost v1 path, leave those signing variables unset, publish the generated SHA-256 checksum, and document Apple's per-app **Privacy & Security > Open Anyway** flow. Recursive quarantine removal is a diagnostic fallback, not the normal installation path. 5. Prepare and package the macOS AI runtime archive for Apple Silicon: `./tools/prepare-ai-runtime.ps1 -Platform macos -RuntimeRoot build-ai-runtime/macos-arm64 -Architecture arm64 -RequirementsFile tools/ai-runtime-requirements-macos.txt -ExpectedRuntimeVersion 1.0.0 -StandaloneReleaseTag 20260325 -StandalonePythonVersion 3.10.20` `./tools/package-ai-runtime.ps1 -Platform macos -RuntimeRoot build-ai-runtime/macos-arm64 -OutputPath dist/ai-runtime/OpenStudio-AI-Runtime-macos-arm64.zip -ExpectedRuntimeVersion 1.0.0` Intel macOS AI runtime support is currently disabled because the pinned `audio-separator` dependency stack does not publish a satisfiable Intel macOS wheel set for the release path. 6. Generate updater metadata with the DMG path and URL included. - For Sparkle-ready appcasts, also pass `-MacEdSignature <signature>` and optionally `-MacMinimumSystemVersion 13.0`. + For Sparkle-ready appcasts, also pass `-MacEdSignature <signature>` and optionally `-MacMinimumSystemVersion 12.0`. 7. Validate the generated metadata: `./tools/validate-release-metadata.ps1 -MetadataDir dist/release-metadata -Channel stable -MacAssetPath dist/macos/OpenStudio-macOS.dmg -MacArm64AiRuntimeAssetPath dist/ai-runtime/OpenStudio-AI-Runtime-macos-arm64.zip` 8. Stage the uniquely named GitHub Release metadata assets: @@ -171,7 +219,12 @@ The default base app no longer bundles the optional stem-separation Python runti ## Secrets expected by GitHub Actions -For the current release path, the only non-signing secret required for public metadata publishing is the cross-repo website dispatch token. If you want Doppler-backed secret loading, add `DOPPLER_TOKEN` as the single bootstrap secret in GitHub Actions. The signing/notarization secrets below stay optional unless you decide to enable trusted distribution later. +For the current release path, `OPENSTUDIO_WEBSITE_DISPATCH_TOKEN` must be set +directly as a GitHub Actions secret because the publish job intentionally does +not receive Doppler credentials. `DOPPLER_TOKEN` is an optional bootstrap for +the allowlisted build/signing values used inside their specific build steps; it +does not replace the website dispatch secret. Signing/notarization secrets stay +optional unless you decide to enable trusted distribution later. - `MACOS_CODESIGN_IDENTITY` - `MACOS_CERTIFICATE_BASE64` diff --git a/docs/release-smoke-checklist.md b/docs/release-smoke-checklist.md index af4e180..3129833 100644 --- a/docs/release-smoke-checklist.md +++ b/docs/release-smoke-checklist.md @@ -6,30 +6,64 @@ Use this checklist for every release candidate before publishing installers, man - Run `./tools/run-windows-rc.ps1 -Version <candidate-version>` before pushing any release tag. - Do not tag a release until the local Windows RC installer path has been validated successfully in both normal startup and `--ui-safe-mode`. +- Treat `--startup-self-test` as dependency/asset preflight only. It does not + replace a visible `boot-ready` result from the packaged frontend. +- A Debug pass is not a Windows Release pass. Run the lifecycle checks against + the installed Release executable. + +## Window Lifecycle Matrix + +Run this matrix from the packaged app while audio is active. Repeat close/reopen, +rapid-close-during-load, sleep/wake, and a second cold launch on each real-machine +release-candidate platform. + +| Window role | Required lifecycle | Success signal | +|---|---|---| +| Main shell | Cold launch, close, relaunch | `boot-ready` and responsive UI | +| Detached Mixer | Detach/open, close, reopen | `boot-ready`; audio continues | +| Detached MIDI editor | Detach, dock/close, reopen; repeat with two different MIDI sessions | `boot-ready`; the correct session returns | +| Built-in effect editor | Open, close during load, reopen | `boot-ready`; controls and audio recover | +| Third-party plug-in editor | Open, close, reopen at least one available native editor | Native editor paints; audio continues without a blank or hung window | ## Windows - Install `OpenStudio-Setup-x64.exe` on a clean machine or VM. +- Repeat the installed Release check on real Windows 10 and Windows 11 standard-user machines; include one machine with Controlled Folder Access or comparable endpoint policy when available. - Confirm the installer provisions or repairs WebView2 Runtime and VC++ Redistributable before offering `Launch OpenStudio`. - Confirm the installer shows which step it is on while copying files, installing VC++, installing WebView2, and validating shell startup. - Confirm the installed app launches without a frontend dev server running. - Confirm the installed app does not show a full black window. -- Confirm `webui`, `effects`, `scripts`, `models`, and `ffmpeg.exe` are present in the installed app directory. +- Confirm `webui`, `effects`, `scripts`, `models`, and the checksum-pinned + `ffmpeg.exe` are present in the installed app directory. +- Confirm `licenses/FFmpeg-COPYING.GPLv3.txt` and + `licenses/FFmpeg-PROVENANCE.json` are present, and confirm the matching + complete corresponding-source distribution is available before publication. +- Confirm the checksum-validated YSFX/WDL, dr_libs, stb, CLAP, Signalsmith, + ARA, and Basic Pitch notices are present under `licenses/`. Also confirm the + notices copied from the pinned NAMCore/Eigen source and, when enabled, the + provenance-verified ONNX Runtime installation are present. - Confirm `prereqs/windows/MicrosoftEdgeWebView2RuntimeInstallerX64.exe` and `prereqs/windows/vc_redist.x64.exe` are present in the installed app directory. - Confirm `%APPDATA%\OpenStudio\logs\OpenStudio_Startup.log` is created on first launch. - Confirm the startup log reports `Embedded browser backend supported: Yes`. - Confirm the startup self-test passes before launch is offered. - Confirm the startup log records `Frontend startup state: boot-ready`. +- Confirm preflight and every WebView2 role use + `%APPDATA%\OpenStudio\WebView2UserData`, outside the protected installation + directory, and confirm the blank-window watchdog remains active. - Confirm a missing `basic_pitch_nmp.onnx` model does not block the base app shell from launching. - Confirm `OpenStudio.exe --ui-safe-mode` renders the safe startup UI visibly. - If startup fails, run `./tools/inspect-installed-windows-app.ps1` on the test machine and archive the generated report. - Open a blank project and confirm audio devices enumerate successfully. +- On a real audio interface, switch input/output device, sample rate, and buffer + size; then monitor, record, play, export, sleep/wake, and relaunch. - Create an audio track, arm it, and confirm monitoring works. - Import an audio file and confirm waveform peaks appear. - Save a new project as `.osproj`. - Open the saved `.osproj` by double-clicking it in Explorer. - Open a legacy `.s13` project and confirm it loads. - Open the mixer, add a built-in OpenStudio effect, and confirm audio still passes. +- Scan, open, close, and reopen at least one available VST3 editor and one CLAP + editor while audio is active. - Confirm the base install does not include a bundled `python/` runtime folder. - Open Stem Separation and confirm it shows the `Install AI Tools` CTA when the optional runtime is missing. - Click the toolbar AI Tools button beside Settings and confirm it opens the same install/help path. @@ -46,14 +80,28 @@ Use this checklist for every release candidate before publishing installers, man ## macOS - Install the `.dmg` output on a clean machine. +- Exercise the packaged app on real Apple Silicon and Intel Macs, including the + oldest supported macOS on at least one machine; hosted runners and a universal + slice check do not replace this gate. - Confirm the app launches offline without a frontend dev server. - Confirm runtime assets are bundled inside the app resources. - Confirm the app bundle startup self-test passes. - Confirm the startup log reports the packaged frontend and shell-critical startup assets as present. -- Confirm the unsigned DMG mounts and the app launches after the documented Gatekeeper override flow (`right-click > Open`, then allow in Privacy & Security if needed). +- Starting with a freshly browser-downloaded DMG, record `xattr -p com.apple.quarantine <dmg>` and the exact Gatekeeper result before changing the artifact. +- Confirm the unsigned DMG mounts and the app launches after Apple's per-app Gatekeeper override flow (attempt launch, then **Privacy & Security > Open Anyway**). Do not remove quarantine before this check. +- Run the native-window lifecycle smoke test against the packaged app and require `boot-ready` from main, Mixer, MIDI, and built-in editor views across close/reopen cycles. +- If signing/notarization credentials are enabled, require `codesign --verify --deep --strict`, `spctl` acceptance, and a successful first launch with quarantine intact. - If startup is forced to fail, confirm the startup doctor/fallback identifies the failure branch and shows the log/safe-mode recovery path. - Open a blank project and confirm audio device setup works. +- With a real CoreAudio interface, switch input/output device, sample rate, and + buffer size; grant microphone access, then monitor, record, play, render, + sleep/wake, and relaunch. +- Scan, open, close, and reopen available VST3, CLAP, and AU editors while audio + is active. - Import audio, edit, and export a short render. +- Confirm WAV/AIFF/FLAC work without FFmpeg. Then install a system `ffmpeg` on + `PATH` and confirm an MP3/OGG conversion succeeds; the app bundle itself must + not contain an unpinned `ffmpeg` binary. - Save a new `.osproj` project and reopen it manually from Finder. - Open a legacy `.s13` project and confirm it loads. - Confirm the base app bundle does not include a bundled `python/` runtime folder. @@ -62,6 +110,21 @@ Use this checklist for every release candidate before publishing installers, man - Trigger `Check for Updates...` and confirm the stable manifest request succeeds. - Validate update behavior from the previous public version to the candidate build. +## Linux + +- Validate both the raw Release output and the extracted AppImage payload with + `tools/validate-runtime-bundle.ps1 -Platform linux -ExpectedVersion <version> + -EnforceLeanBundle`. +- Confirm `OpenStudio.version` exists and exactly matches the candidate version; + a missing, empty, or mismatched manifest is a release failure. +- Run `--startup-self-test` under `xvfb-run`, then launch the AppImage visibly on + an x86-64 desktop and confirm the frontend reaches `boot-ready`. +- Confirm no FFmpeg binary is bundled. Test one FFmpeg-backed operation both + without `ffmpeg` on `PATH` (actionable failure) and with the supported system + package installed (successful operation). +- Test local NAM loading without a keyring. When TONE3000 sign-in is enabled, + also test token persistence with `secret-tool` and an active Secret Service. + ## Updater And Release Metadata - Confirm `releases/latest.json` and `releases/stable/latest.json` match. @@ -90,3 +153,6 @@ Use this checklist for every release candidate before publishing installers, man - Confirm known issues are documented. - Confirm support contact details are published. - Confirm rollback instructions are ready if the update feed needs to be reverted. +- Record signing/notarization state separately from first-launch reputation. + A valid signature alone is not proof of SmartScreen reputation or Gatekeeper + acceptance. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..6b91f0f --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,57 @@ +# OpenStudio Roadmap + +This public roadmap describes product direction, not fixed delivery dates or a +promise that every exploratory item will ship. Current capabilities and caveats +live in [Implemented features](implemented_features.md); release qualification +lives in [Testing](testing.md) and the +[Release smoke checklist](release-smoke-checklist.md). + +## Now: release quality + +- Complete release qualification for the NAM Rack and optional TONE3000 + workflow, including multi-capture selection, Guitar/Bass profiles, project and + preset recovery, accessibility, and real-interface listening tests. +- Keep Windows, macOS, and Linux installation, startup, updates, and optional AI Tools + setup reliable on clean systems. +- Preserve old projects and presets while strengthening audio-thread safety, + deterministic state migration, and failure recovery. + +## Next: DAW foundations + +- Finish the remaining MIDI playback, routing, note-lifecycle, hardware-output, + and plug-in-generated MIDI workflows across live playback and offline render. +- Bring CLAP instrument/event handling and state restoration to the same + product standard as the reference VST3 path. +- Unify menus and contextual commands around the action registry so shortcuts, + enablement, undo, and visible actions remain consistent. +- Complete and test the render/export options that OpenStudio advertises, + including presets, queue behavior, metadata, failure cleanup, and project + round trips. +- Improve project-wide media, FX, track/group, navigation, and floating-window + management. + +## Exploring + +- An optional local DAW assistant that selects a model appropriate for the + user's hardware, keeps project context local and bounded, previews every + mutating action, and uses OpenStudio's normal undo-aware commands. +- Wider hybrid-precision processing where it produces measurable value without + compromising plug-in compatibility or the default float32 workflow. +- More portable tone/library workflows, including cross-device metadata and + safe shared-asset management. +- Future pitch-rendering or restoration research when a materially stronger, + testable approach becomes available. +- A native extension SDK if demand justifies a stable ABI and long-term + compatibility commitment; Lua and JSFX remain the supported extension paths + today. + +## Product guardrails + +- OpenStudio will not bundle third-party NAM captures or cabinet IRs without + clear redistribution permission. +- Automated measurements will not be presented as proof of subjective tone, + naturalness, or commercial-product parity. +- Experimental controls will not be exposed as working product features before + their complete signal path, persistence, and tests exist. +- Retired NAM Rack controls and misleading decorative routing will not return + without a new product decision and full QA. diff --git a/docs/runtime-dependency-contract.md b/docs/runtime-dependency-contract.md index 0a9d565..72dbc18 100644 --- a/docs/runtime-dependency-contract.md +++ b/docs/runtime-dependency-contract.md @@ -19,6 +19,17 @@ If a hard prerequisite is missing or unusable: - the startup doctor must log the exact failure branch - the user must get an actionable recovery path +### Startup Evidence + +`--startup-self-test` verifies dependency discovery, packaged shell assets, and +whether an embedded-browser environment can be requested. It does **not** prove +that a window rendered the React application. + +`boot-ready` is the stronger UI signal emitted after a browser role has loaded +its frontend. Release qualification requires both checks. The browser startup +watchdog must remain active and must turn a missing `boot-ready` signal into a +diagnosable failure instead of leaving a blank window. + ## Bundled Feature Assets These are bundled with OpenStudio and should be present in the installed/runtime bundle, but they must not block the base shell from launching. @@ -27,7 +38,14 @@ These are bundled with OpenStudio and should be present in the installed/runtime - `effects/` - `scripts/` - `models/basic_pitch_nmp.onnx` -- `ffmpeg` / `ffmpeg.exe` +- `models/basic_pitch_nmp.provenance.json` +- `OpenStudio.version`, generated from the same CMake version compiled into the + application and validated without launching a GUI process +- `LICENSE`, `THIRD_PARTY_LICENSES.md`, and the packaged dependency notices + under `licenses/`; notices with repository-pinned digests are checksum + validated before packaging +- Windows: the checksum-pinned `ffmpeg.exe` plus its exact GPL and provenance + files If a bundled feature asset is missing: @@ -41,7 +59,14 @@ These must never block base app launch. - Python for AI tools - AI models and downloadable AI helper runtimes -- ONNX Runtime +- ONNX Runtime in custom builds and on platforms where it is not provisioned; + official Windows/Linux releases provision the pinned runtime +- Linux `secret-tool` (normally provided by `libsecret-tools`) and an available + Secret Service/keyring for optional TONE3000 sign-in; local NAM loading does + not depend on it +- macOS/Linux: a system `ffmpeg` on `PATH` for MP3/OGG export, video-audio + extraction, FFmpeg-backed time stretch/pitch shift, and conversions that need + FFmpeg. OpenStudio does not redistribute an unpinned Unix FFmpeg binary. - ASIO - plugin-vendor-specific external runtimes @@ -57,6 +82,12 @@ If an optional dependency is missing: - The installer owns hard launch prerequisites. - The packaged app stages offline Windows prerequisite installers in `prereqs/windows`. +- Browser capability checks and browser construction must use the same shared + options factory. Every WebView2 role uses the writable per-user data folder + `%APPDATA%\OpenStudio\WebView2UserData`; no installed path may fall back to a + user-data folder beside the executable under `Program Files`. +- A Debug browser pass is not evidence that the MSVC Release build works. The + packaged Release executable must complete the frontend-ready lifecycle gate. - The startup doctor must distinguish: - WebView2 not installed - WebView2 installed but unusable @@ -66,12 +97,54 @@ If an optional dependency is missing: ### macOS - The app relies on system WebKit; no separate browser runtime installer is bundled. +- The Basic Pitch model is bundled for provenance consistency, but the current + macOS release pipeline does not provision ONNX Runtime, so Basic Pitch + inference is unavailable in that build. +- FFmpeg-backed features require a system FFmpeg on `PATH`; FFmpeg is not + bundled in the macOS app. - The startup doctor must distinguish: - backend unavailable on the current system - shipped runtime asset missing - packaged frontend missing - Safe mode and the startup log must remain available for recovery. +### Linux + +- Release validation must read `OpenStudio.version` and fail on a missing, + empty, or mismatched manifest; it must not launch the GUI binary for a + best-effort version check. +- FFmpeg-backed features use the system `ffmpeg` installed by the distribution; + OpenStudio does not copy a developer-local FFmpeg into the AppImage. +- Missing FFmpeg must disable only the affected conversion operation and must + produce an actionable diagnostic rather than blocking startup. + +## Embedded and Native Window Roles + +The embedded-browser lifecycle contract covers the main shell, detached Mixer, +each detached MIDI editor, and each built-in effect editor. Each role must reach +`boot-ready`, survive close/reopen cycles, and release closed secondary views. +Third-party plug-in editors use their native JUCE editor path and are qualified +separately for open/close/reopen behavior while audio is running. The graphical +Pitch Editor remains part of the main window rather than a detached browser +role. + +## JUCE Dependency Policy + +OpenStudio is pinned to JUCE `9.0.1`. A JUCE upgrade must be an isolated, +reviewable dependency change with Debug and Release builds plus audio-device, +plug-in-host, browser lifecycle, and packaging qualification. OpenStudio's +realtime JUCE patches must fail closed when their expected upstream source +context changes; an upgrade must never silently skip or partially apply them. + +## Distribution Trust Boundary + +Signing, launch reputation, and runtime health are separate gates. A valid +signature is not automatically reputation-clean on Windows, and a self-signed +binary is not a substitute for established Authenticode reputation. On macOS, +signature verification does not replace notarization/Gatekeeper assessment. +Unsigned releases may use the documented user-approved first-launch path, but +must not describe that path as warning-free. + ## AI Tools Contract - Clicking the toolbar AI button may start optional setup work. diff --git a/docs/stable_audio_3_integration_plan.md b/docs/stable_audio_3_integration_plan.md deleted file mode 100644 index 164cd30..0000000 --- a/docs/stable_audio_3_integration_plan.md +++ /dev/null @@ -1,148 +0,0 @@ -# Stable Audio 3 + ACE-Step Source-Audio Workflow Checklist - -## Summary - -- [x] Keep the current ACE model/version exactly as-is: `ACE-Step 1.5 XL Turbo`. -- [x] Add `Stable Audio 3 Medium` as a separate selectable model with its own runtime, setup, and params. -- [x] Add supported source-audio workflows for both models from the clip context menu. -- [x] Do not implement ACE Extract, Lego, or Complete: no UI entries, workflow IDs, backend branches, or disabled placeholders. - -## AI Track Flow - -- [x] Keep AI tracks prompt-first. -- [x] Add a compact model dropdown in the AI track header: - - `ACE-Step 1.5 XL Turbo` - - `Stable Audio 3 Medium` -- [x] Add the same model dropdown at the top of the AI track params modal. -- [x] Make model changes update the workflow dropdown and visible params form. -- [x] Limit AI track workflows to: - - ACE-Step: `Text to Music`, `Lyrics + Style` - - Stable Audio 3: `Text to Audio` -- [x] If the selected model is unavailable, turn Generate into a setup CTA focused on that model. - -## Clip Context Menu Flow - -- [x] Add an `AI Generation` submenu to the existing audio clip right-click menu, near `Separate Stems...`. -- [x] Show the submenu only for audio clips. -- [x] Add menu items: - - `Create Variation...` - - `Inpaint Selection...` - - `Continue Clip...` -- [x] Disable `Inpaint Selection...` unless the current time selection overlaps the clicked clip. -- [x] Open a new `AI Clip Generation` modal from each menu item. -- [x] Include source clip name, source track name, and duration/range summary in the modal. -- [x] Limit modal model choices to models that support the selected source workflow. -- [x] Include workflow-specific params, setup/status block, and Generate/Cancel footer. -- [x] Do not show ACE Extract/Lego/Complete anywhere. - -## Output Placement - -- [x] Make source-audio workflows nondestructive by default. -- [x] `Create Variation...`: create a new audio track directly below the source track named `AI Variation - <clip name>`, aligned to the source clip start. -- [x] `Inpaint Selection...`: create a new audio track directly below the source track named `AI Inpaint - <clip name>`, aligned to the source clip start. -- [x] `Continue Clip...`: insert only the generated tail at `sourceClip.startTime + sourceClip.duration`. -- [x] Place continuation on the source track when clear; otherwise create `AI Continuation - <clip name>` below the source track. -- [x] Make all generated track/clip additions undo-tracked. - -## Workflow + Params Registry - -- [x] Replace the single ACE-only workflow registry with a model-aware registry. -- [x] Add model IDs: - - `ace-step-v15-xl-turbo` - - `stable-audio-3-medium` -- [x] Add workflow IDs: - - `text-to-music` - - `lyrics-style` - - `text-to-audio` - - `variation` - - `inpaint-selection` - - `continue-clip` -- [x] Declare supported model IDs, surface, params schema, and source requirements per workflow. -- [x] Add ACE source params: prompt, lyrics, seed, duration/extension duration, inference steps, guidance/cfg, `audio_cover_strength`, repaint start/end. -- [x] Add Stable Audio params: prompt, negative prompt, seed, duration/extension duration, steps, cfg scale, source strength/noise amount, inpaint range. -- [x] Add Stable Audio Advanced LoRA inference fields for optional `.safetensors` path and strength. - -## Frontend State + UI - -- [x] Add `aiMusicModelId` to AI tracks and default existing projects to ACE-Step. -- [x] Add modal state for clip generation source, workflow, model, params, and validation/status. -- [x] Build `AIClipGenerationModal` as a sibling to `StemSeparationModal`. -- [x] Reuse current dark theme tokens, compact controls, existing modal/footer layout, and existing UI components. -- [x] Convert time selection to clip-relative inpaint range: - - `rangeStart = max(timeSelection.start, clip.startTime) - clip.startTime` - - `rangeEnd = min(timeSelection.end, clipEnd) - clip.startTime` - - disable when `rangeEnd <= rangeStart` - -## Backend + Bridge - -- [x] Extend generation start API to `startAIGeneration(trackId, modelId, workflowId, paramsJSON)`. -- [x] Preserve old three-argument behavior by treating it as ACE-Step. -- [x] Enforce one global generation job at a time for v1. -- [x] Refactor `AITrackEngine` into provider routing. -- [x] Keep ACE provider on the existing persistent ACE worker. -- [x] Add a separate Stable Audio provider/runtime. -- [x] Include source file path, clip offset, clip duration, source track/clip IDs, inpaint range, or extension length in source params. -- [x] Prepare exact source-segment temp WAV before inference so trimmed clips behave correctly. -- [x] Map ACE Variation to Cover with `src_audio` and `audio_cover_strength`. -- [x] Map ACE Inpaint to Repaint with `src_audio`, `repainting_start`, and `repainting_end`. -- [x] Map ACE Continue to Repaint at the source clip end and crop the output to the generated tail before import. -- [x] Map Stable Audio Variation, Inpaint, and Continue to its source-conditioned workflows. -- [x] Add optional `modelId`, `workflowId`, and `sourceClipId` to progress payloads. - -## Setup Flow - -- [x] Add a music model section under AI Tools Setup > Audio Generation. -- [x] Keep ACE setup unchanged. -- [x] Add Stable Audio 3 Medium setup as strict opt-in. -- [x] Show Stability/Gemma license notice and require explicit checkbox confirmation. -- [x] Show `Powered by Stability AI` attribution when Stable Audio is selected or used. -- [x] Use manual Hugging Face download/import, not stored HF credentials. -- [x] Add `Open Hugging Face Model Page`. -- [x] Show required folder layout help text. -- [x] Add `Proceed with Setup` folder picker and validation. -- [x] Validate Stable Audio folder files: - - `model.safetensors` - - `model_config.json` - - `LICENSE.md` - - `LICENSE_GEMMA.md` - - `NOTICE` - - `t5gemma-b-b-ul2/model.safetensors` - - `t5gemma-b-b-ul2/config.json` - - `t5gemma-b-b-ul2/tokenizer.json` - - `t5gemma-b-b-ul2/tokenizer.model` - - `t5gemma-b-b-ul2/tokenizer_config.json` - - `t5gemma-b-b-ul2/special_tokens_map.json` -- [x] Validate the initial local target `C:\Users\srvds\Downloads\stable_audio_3`. -- [x] Copy/import the snapshot into a managed OpenStudio model folder. -- [x] Keep Stable Audio runtime separate from ACE. - -## Test + Visual Harness - -- [x] Add unit tests for model-aware workflow filtering, default params, normalization, inpaint range conversion, disabled inpaint, and no unsupported workflow IDs. -- [x] Add store tests for model changes, undoable generated source outputs, and continuation placement. -- [x] Add installer/probe tests for Stable Audio folder validation and license confirmation. -- [x] Add `tools/ai-generation-ui-harness.mjs`. -- [x] Harness scenario: `ai-track-model-selector`. -- [x] Harness scenario: `clip-context-ai-menu`. -- [x] Harness scenario: `ai-clip-generation-modal`. -- [x] Harness scenario: `stable-audio-setup`. -- [x] Harness checks desktop and compact screenshots. -- [x] Harness checks text overflow, viewport bounds, menu placement, and theme alignment. -- [x] Harness writes `qa/ai-generation/<date>/screenshots` and `qa/ai-generation/<date>/report.json`. - -## Verification - -- [x] Run `npx tsc --noEmit`. -- [x] Run the frontend visual harness. -- [x] Build frontend assets into `frontend/dist`. -- [x] Run `cmake --build build --config Debug`. -- [x] Stop Codex-started dev servers and leave port `5173` free. -- [ ] Manual check: ACE text generation from AI track. -- [ ] Manual check: ACE lyrics generation from AI track. -- [ ] Manual check: ACE clip variation. -- [ ] Manual check: ACE inpaint with overlapping time selection. -- [ ] Manual check: ACE continuation. -- [ ] Manual check: Stable Audio setup using `C:\Users\srvds\Downloads\stable_audio_3`. -- [ ] Manual check: Stable Audio text generation from AI track. -- [ ] Manual check: Stable Audio variation, inpaint, and continuation. -- [ ] Verify generated WAVs import, appear at expected positions, play back, and can be undone. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..b169d2f --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,243 @@ +# NAM and Audio QA + +OpenStudio separates deterministic engineering evidence from listening +judgment. Every result must be reported as: + +- `pass` — a deterministic requirement succeeded. +- `fail` — a deterministic requirement failed. +- `diagnostic_only` — useful measurement that is not a release guarantee. +- `not_asserted` — requires a person to audition or inspect the exact build. + +## Fast release checks + +From `frontend/`: + +```bash +npm test +npx tsc --noEmit +npm run build +npm run test:e2e +``` + +From the repository root: + +```powershell +cmake --build build --config Debug +powershell -ExecutionPolicy Bypass -File tools/run-clean-guitar-headless-regression.ps1 -SkipBuild +powershell -ExecutionPolicy Bypass -File tools/run-nam-rack-di-headless-regression.ps1 -SkipBuild +powershell -ExecutionPolicy Bypass -File tools/run-nam-rack-headless-regression.ps1 -SkipBuild +``` + +The full NAM Rack matrix defaults to a 360-second timeout. It currently takes +about three minutes in a Debug build; use `-TimeoutSeconds` only when a slower +CI runner needs additional margin, not to conceal a hung regression. + +Before a release: + +```powershell +cmake --build build --config Release +python build.py prod +``` + +The three PowerShell regressions are intentionally headless. They must not open +an OpenStudio window. `tools/nam-rack-visual-harness.mjs` is retained for +targeted browser/layout capture; generated screenshots and reports are ignored. + +## Frontend visual and interaction QA + +Every resizable frontend surface must be checked at its supported compact, +normal, and large widths; at compact and normal heights; and at representative +HiDPI/Windows display scaling. The release gate is behavioral and geometric: + +- no horizontal page overflow, accidental host gutter, clipped control, or + overlapping visual/hit target; +- labels wrap or truncate deliberately, focus remains visible, and accessible + names and pointer/keyboard targets remain intact; +- browser geometry and screenshot checks cover the rendered result, while + interaction tests cover scrolling, focus, keyboard use, and control travel; +- `npx tsc --noEmit`, the frontend tests, the Vite production build, and the + packaged Debug WebView assets all use the same current source. + +Exact CSS text is not a visual oracle. Tests may enforce global architecture +rules such as the absence of runtime styles and `!important`, but layout +acceptance must inspect the real rendered surface. + +## Audio signal-chain triage + +Pitch, playback, and render artifacts must be localized before DSP tuning. + +- Live: route resolution -> `PlaybackEngine` read/mix -> `TrackProcessor` -> + sends/sidechain -> master/monitor FX -> gain/pan/mono -> meters -> device. +- Render: render `PlaybackEngine` snapshot -> `TrackProcessor` -> master FX and + gain -> writer. + +Set `OPENSTUDIO_AUDIO_CHAIN_DEBUG=1` to write a debug packet beside a render, +or set `OPENSTUDIO_AUDIO_CHAIN_DEBUG_DIR` to choose the directory. The packet +contains `render_chain_report.json`, `playback_output.wav`, +`track_post_processing.wav`, `master_pre_fx.wav`, `master_post_fx.wav`, and +`writer_input.wav`. `OPENSTUDIO_AUDIO_CHAIN_DEBUG_MAX_SEC` bounds capture time +and defaults to 12 seconds. + +Find the first dirty stage: source/routing/read conversion when playback is +already dirty; processor state, bypass, default EQ/gain, and denormals when the +track/master stage first changes; alignment, format conversion, dither, block, +and tail handling when only writer input/output is dirty; callback deadlines, +resizes, lock misses, IPC/timer pressure, and preview cleanup when only live +playback is dirty. A clean render rules out WebView IPC and live-device +underruns as the render artifact's cause. Do not close a noise issue until the +first dirty stage is identified, deterministic checks pass, and the exact +artifact is auditioned. + +## Reference-comparison capture protocol + +Use the same 10-15 second dry DI for both systems, including sustained chords, +single notes, palm-muted transients, decay, and silence. Record the exact preset +and controls, sample rate, buffer, input peak, output loudness, and mono/stereo +route. Capture wet-only output where possible and loudness-match with LUFS/RMS +before judging tone. Test a real `L = guitar, R = silence` route separately +from duplicated stereo input so lane faults cannot hide. + +Reference files are QA oracles only and production must never read or depend on +them. Change one audible issue per iteration. Spectral and level measurements +remain `diagnostic_only`; perceived similarity and transition quality remain +`not_asserted` until a person approves the exact artifacts. + +## What automation may prove + +- A1/A2 fixture loading and invalid-model rejection. +- Finite, bounded processing and expected relative pitch requests. +- Prepared model/IR swaps and stale-request rejection. +- Sample-rate conversion and callback-partition invariance. +- Fixed reported latency and dry-path alignment. +- Project, preset, A/B, order, calibration, and automation identity round trips. +- Preview Use/Cancel rollback. +- Multi-capture pack collapse/hydration, exact child identity (including + URL-only/model-ID-zero cases), selection without publication, preview + supersession, Use, replace, bypass, and unload state transitions. +- Missing-asset recovery and durable download identity. +- TONE3000 session-state transitions with deterministic mocks. +- Output-file creation and explicit render-route state. + +CPU percentages, spectra, formant estimates, and fixture-specific deadline +measurements are `diagnostic_only`. + +## Guitar/Bass instrument-profile release matrix + +The automated gate must cover all of the following before merging: + +1. Missing, legacy, out-of-range, NaN, and infinite profile state canonicalizes + to Guitar; a valid Bass value survives project, user-preset, import/export, + and Compare A/B round trips. The instrument selector is intentionally not a + user-automatable parameter; separate DSP stress tests exercise live profile + switches across callback boundaries. +2. Switching Guitar -> Bass -> Guitar does not change any stored visible value, + model/capture path, cabinet IR path, calibration value, or explicit cutoff. +3. EQ Boost centres/labels, Octaver tracking, both nonlinear pedals, Amp wrapper + and tone stack, Graphic EQ low band, modulation direct path, Delay repeat + filtering, and non-legacy Reverb low decay exercise their intended profile + mapping with finite bounded output. +4. The live switch is deterministic across fixed and uneven callback partitions, + remains bounded at the exact transition samples, and latches one coherent + profile for a complete audio callback during concurrent UI publication. + Octaver coverage spans 44.1, 48, and 96 kHz. +5. Catalog and installed-library views filter only explicitly incompatible + metadata, keep untagged/shared captures, and pin an already-active opposite- + tagged capture without unloading or replacing it. +6. Changing away from an instrument-specific factory template clears only the + stale template identity/baseline. Shared templates and user presets retain + their normal dirty-state behavior. + +The final musician audition uses clean Guitar and five-string Bass DI fixtures. +For each instrument, enable one stage at a time, then the complete chain; toggle +the profile during sustained notes, palm mutes, silence, modulation, Delay and +Reverb tails; load/change/remove, bypass/re-enable, and replace captures and IRs; +save and recall multiple presets; restart the app; and repeat at small and normal +device buffers. Record +the rack output while switching and inspect/listen for clicks, dropouts, stale +audio, DC steps, unexpected repeat/tail resurrection, and noise-floor changes. +Those listening outcomes remain `not_asserted` until a person approves the exact +release artifact. + +## What automation may not prove + +The following remain `not_asserted` until the user auditions the exact artifact: + +- naturalness, realism, commercial-product sonic parity, or "better tone"; +- pick response, palm-muted tightness, chord separation, sustain, or noise feel; +- click-free perception during power/model/cabinet changes; +- tuner feel on a real decaying guitar; +- chorus width, mono compatibility, delay feel, reverb density, or shimmer + musicality; +- absence of real-device crackle across interfaces and drivers. + +## TONE3000 first-user acceptance + +Run this with a clean OS user or after removing only OpenStudio's saved +TONE3000 session: + +1. Install the release candidate and open the NAM Rack. +2. Select Connect TONE3000. +3. Confirm the default browser opens the official TONE3000 page. +4. Create a new account or sign in as a first-time OpenStudio user. +5. Confirm the browser success page returns control to the app without copying + a token or entering developer settings. +6. Search separately for A1 and A2; confirm combined results are bounded, + paginated, attributed, and responsive to rate limits. +7. Open a tone pack that declares multiple captures. Confirm the pack appears + once, its count is correct, and **View Captures** exposes every hydrated + child with its architecture and RAW/CAB-embedded topology. +8. Select child A and confirm selection alone leaves the current audible model + unchanged. Audition A, switch directly to audition child B, then Stop and + confirm the complete pre-preview baseline returns. +9. Audition child A again and choose **Use**. Confirm the nameplate, exact child + identity, source attribution, amp/cab topology, and saved metadata all refer + to A rather than the pack sentinel or first child by accident. +10. Reopen the picker and Use child B to replace A. If B embeds a cabinet, + confirm the external Cab is bypassed without deleting its prior IR; return + to an amp-only child and confirm the normal Cab workflow resumes. +11. Bypass and re-enable the Amp slot without losing B, then Unload and confirm + the slot is empty. Reload B, save the project/tone, restart OpenStudio, and + confirm the exact child and enabled state restore. +12. Start another preview and Cancel it; confirm the restored state includes + capture, Cab, power, and mix values. Repeat while a prior request is still + preparing and confirm a stale completion cannot overwrite the newer choice. +13. Install one permitted cabinet IR and confirm it becomes audible only where + the selected capture needs an external cabinet. +14. Force or wait for token refresh and repeat a search/download. +15. Delete one downloaded child asset, reopen the project, and verify Locate, + Replace, Bypass, and supported Re-download recovery without disabling the + rest of the rack. + +The deterministic frontend/unit suite separately covers pack-sentinel/model-ID-zero +and URL-only child identities when the live catalog does not expose such a +record during the acceptance run. + +Record the app version, OS, account state, model/IR IDs, result for each step, +and screenshots of any failure. Do not include tokens or the publishable key in +the report. + +## Manual audio matrix + +Use clean Guitar and five-string Bass DIs, the user's normal interface, and at +least one A1 and one A2 capture: + +- 44.1, 48, and 96 kHz where the device supports them; +- 32, 64, 128, 256, and 512-sample buffers where stable; +- mono and stereo track paths; +- amp-only capture plus IR and full-rig capture with external Cab bypassed; +- each native pedal alone and representative combinations; +- selection-only, audition/stop, audition-to-audition, Use, model replacement, + cabinet, preset, A/B, bypass/re-enable, unload, and missing-asset transitions; +- live playback and offline render of the same phrase. + +Report objective invariants separately from the user's listening verdict. + +## Optional AI-generation acceptance + +When ACE-Step or Stable Audio generation changes, manually exercise ACE prompt +and lyrics generation plus variation, inpaint, and continuation; then exercise +Stable Audio setup/license acknowledgement, prompt generation, variation, +inpaint, and continuation with a valid local snapshot. Generated WAVs must +import at the intended positions, play, persist through save/reopen, and undo +as one user action. A missing optional model/runtime must not block base-app +startup. diff --git a/effects/README.txt b/effects/README.txt index ded7766..d3cd6a9 100644 --- a/effects/README.txt +++ b/effects/README.txt @@ -1,11 +1,11 @@ -Studio13 Custom JSFX Effects +OpenStudio Custom JSFX Effects ============================ Place your custom .jsfx scripts in this directory. -They will be bundled with Studio13 and appear in the Plugin Browser under "S13FX". +They will be bundled with OpenStudio and appear in the Plugin Browser under "S13FX". For user-created scripts that persist across updates, use: - Documents/Studio13/Effects/ + Documents/OpenStudio/Effects/ You can open the user effects folder from the Plugin Browser by clicking the folder icon next to the Scan button. diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000..c947119 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +22.23.2 diff --git a/frontend/THIRD_PARTY_NOTICES.txt b/frontend/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..11e3a6d --- /dev/null +++ b/frontend/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,2613 @@ +OpenStudio Frontend Third-Party Notices + +This file is generated from frontend/package-lock.json and the exact license, +copying, and notice files installed in frontend/node_modules. Do not edit it +manually; run `npm run notices:generate` after changing production dependencies. + +Production package instances: 38 + +================================================================================ +@dnd-kit/accessibility@3.1.1 +Installed path: node_modules/@dnd-kit/accessibility +Declared license: MIT +Source: git+https://github.com/clauderic/dnd-kit.git (directory: packages/accessibility) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021, Claudéric Demers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@dnd-kit/core@6.3.1 +Installed path: node_modules/@dnd-kit/core +Declared license: MIT +Source: git+https://github.com/clauderic/dnd-kit.git (directory: packages/core) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021, Claudéric Demers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@dnd-kit/sortable@10.0.0 +Installed path: node_modules/@dnd-kit/sortable +Declared license: MIT +Source: git+https://github.com/clauderic/dnd-kit.git (directory: packages/sortable) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021, Claudéric Demers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@dnd-kit/utilities@3.2.2 +Installed path: node_modules/@dnd-kit/utilities +Declared license: MIT +Source: git+https://github.com/clauderic/dnd-kit.git (directory: packages/utilities) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021, Claudéric Demers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@floating-ui/core@1.7.5 +Installed path: node_modules/@floating-ui/core +Declared license: MIT +Source: https://github.com/floating-ui/floating-ui.git (directory: packages/core) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@floating-ui/dom@1.7.6 +Installed path: node_modules/@floating-ui/dom +Declared license: MIT +Source: https://github.com/floating-ui/floating-ui.git (directory: packages/dom) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@floating-ui/react@0.26.28 +Installed path: node_modules/@floating-ui/react +Declared license: MIT +Source: https://github.com/floating-ui/floating-ui.git (directory: packages/react) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@floating-ui/react-dom@2.1.8 +Installed path: node_modules/@floating-ui/react-dom +Declared license: MIT +Source: https://github.com/floating-ui/floating-ui.git (directory: packages/react-dom) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@floating-ui/utils@0.2.11 +Installed path: node_modules/@floating-ui/utils +Declared license: MIT +Source: https://github.com/floating-ui/floating-ui.git (directory: packages/utils) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021-present Floating UI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@headlessui/react@2.2.9 +Installed path: node_modules/@headlessui/react +Declared license: MIT +Source: git+https://github.com/tailwindlabs/headlessui.git (directory: packages/@headlessui-react) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2020 Tailwind Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--- END LICENSE --- + +================================================================================ +@react-aria/focus@3.21.5 +Installed path: node_modules/@react-aria/focus +Declared license: Apache-2.0 +Source: https://github.com/adobe/react-spectrum + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Adobe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--- END LICENSE --- + +================================================================================ +@react-aria/interactions@3.27.1 +Installed path: node_modules/@react-aria/interactions +Declared license: Apache-2.0 +Source: https://github.com/adobe/react-spectrum + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Adobe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--- END LICENSE --- + +================================================================================ +@react-aria/ssr@3.9.10 +Installed path: node_modules/@react-aria/ssr +Declared license: Apache-2.0 +Source: https://github.com/adobe/react-spectrum + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Adobe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--- END LICENSE --- + +================================================================================ +@react-aria/utils@3.33.1 +Installed path: node_modules/@react-aria/utils +Declared license: Apache-2.0 +Source: https://github.com/adobe/react-spectrum + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Adobe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--- END LICENSE --- + +================================================================================ +@react-stately/flags@3.1.2 +Installed path: node_modules/@react-stately/flags +Declared license: Apache-2.0 +Source: https://github.com/adobe/react-spectrum + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Adobe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--- END LICENSE --- + +================================================================================ +@react-stately/utils@3.11.0 +Installed path: node_modules/@react-stately/utils +Declared license: Apache-2.0 +Source: https://github.com/adobe/react-spectrum + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Adobe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--- END LICENSE --- + +================================================================================ +@react-types/shared@3.33.1 +Installed path: node_modules/@react-types/shared +Declared license: Apache-2.0 +Source: https://github.com/adobe/react-spectrum + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Adobe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +--- END LICENSE --- + +================================================================================ +@swc/helpers@0.5.21 +Installed path: node_modules/@swc/helpers +Declared license: Apache-2.0 +Source: git+https://github.com/swc-project/swc.git (directory: packages/helpers) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2024 SWC contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--- END LICENSE --- + +================================================================================ +@tanstack/react-virtual@3.13.23 +Installed path: node_modules/@tanstack/react-virtual +Declared license: MIT +Source: git+https://github.com/TanStack/virtual.git (directory: packages/react-virtual) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021-present Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@tanstack/virtual-core@3.13.23 +Installed path: node_modules/@tanstack/virtual-core +Declared license: MIT +Source: git+https://github.com/TanStack/virtual.git (directory: packages/virtual-core) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2021-present Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +@types/react@19.2.14 +Installed path: node_modules/@types/react +Declared license: MIT +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git (directory: types/react) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + +--- END LICENSE --- + +================================================================================ +@types/react-reconciler@0.28.9 +Installed path: node_modules/its-fine/node_modules/@types/react-reconciler +Declared license: MIT +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git (directory: types/react-reconciler) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + +--- END LICENSE --- + +================================================================================ +@types/react-reconciler@0.33.0 +Installed path: node_modules/@types/react-reconciler +Declared license: MIT +Source: https://github.com/DefinitelyTyped/DefinitelyTyped.git (directory: types/react-reconciler) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + +--- END LICENSE --- + +================================================================================ +classnames@2.5.1 +Installed path: node_modules/classnames +Declared license: MIT +Source: git+https://github.com/JedWatson/classnames.git + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +The MIT License (MIT) + +Copyright (c) 2018 Jed Watson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +clsx@2.1.1 +Installed path: node_modules/clsx +Declared license: MIT +Source: lukeed/clsx + +--- BEGIN license (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- END license --- + +================================================================================ +csstype@3.2.3 +Installed path: node_modules/csstype +Declared license: MIT +Source: https://github.com/frenic/csstype + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +Copyright (c) 2017-2018 Fredrik Nicol + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +its-fine@2.0.0 +Installed path: node_modules/its-fine +Declared license: MIT +Source: https://github.com/pmndrs/its-fine + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2022-2025 Poimandres + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +konva@9.3.22 +Installed path: node_modules/konva +Declared license: MIT +Source: git://github.com/konvajs/konva.git + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Original work Copyright (C) 2011 - 2013 by Eric Rowell (KineticJS) +Modified work Copyright (C) 2014 - present by Anton Lavrenov (Konva) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +--- END LICENSE --- + +================================================================================ +lucide-react@0.562.0 +Installed path: node_modules/lucide-react +Declared license: ISC +Source: https://github.com/lucide-icons/lucide.git (directory: packages/lucide-react) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2023 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2025. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The MIT License (MIT) (for portions derived from Feather) + +Copyright (c) 2013-2023 Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +react@19.2.4 +Installed path: node_modules/react +Declared license: MIT +Source: https://github.com/facebook/react.git (directory: packages/react) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +react-dom@19.2.4 +Installed path: node_modules/react-dom +Declared license: MIT +Source: https://github.com/facebook/react.git (directory: packages/react-dom) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +react-konva@19.2.3 +Installed path: node_modules/react-konva +Declared license: MIT +Source: git@github.com:konvajs/react-konva.git + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2017 Anton Lavrenov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +react-reconciler@0.33.0 +Installed path: node_modules/react-reconciler +Declared license: MIT +Source: https://github.com/facebook/react.git (directory: packages/react-reconciler) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +scheduler@0.27.0 +Installed path: node_modules/scheduler +Declared license: MIT +Source: https://github.com/facebook/react.git (directory: packages/scheduler) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +tabbable@6.4.0 +Installed path: node_modules/tabbable +Declared license: MIT +Source: git+https://github.com/focus-trap/tabbable.git + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +The MIT License (MIT) + +Copyright (c) 2015 David Clark + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +--- END LICENSE --- + +================================================================================ +tslib@2.8.1 +Installed path: node_modules/tslib +Declared license: 0BSD +Source: https://github.com/Microsoft/tslib.git + +--- BEGIN LICENSE.txt (verbatim; line endings normalized to LF) --- +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +--- END LICENSE.txt --- + +================================================================================ +use-sync-external-store@1.6.0 +Installed path: node_modules/use-sync-external-store +Declared license: MIT +Source: https://github.com/facebook/react.git (directory: packages/use-sync-external-store) + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- + +================================================================================ +zustand@5.0.12 +Installed path: node_modules/zustand +Declared license: MIT +Source: git+https://github.com/pmndrs/zustand.git + +--- BEGIN LICENSE (verbatim; line endings normalized to LF) --- +MIT License + +Copyright (c) 2019 Paul Henschel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- END LICENSE --- diff --git a/frontend/e2e/automation-input-runtime.spec.ts b/frontend/e2e/automation-input-runtime.spec.ts new file mode 100644 index 0000000..ed120bd --- /dev/null +++ b/frontend/e2e/automation-input-runtime.spec.ts @@ -0,0 +1,191 @@ +import { expect, test, type Page } from "@playwright/test"; + +const PROFILE_SETTINGS_KEY = "openstudio.inputProfiles.v1"; +const PRIMARY_KEY = process.platform === "darwin" ? "Meta" : "Control"; + +async function timelineCanvasPatchHash( + page: Page, + clientX: number, + clientY: number, + radius = 10, +): Promise<number> { + return page.evaluate(({ x, y, r }) => { + let hash = 2166136261; + let sampled = false; + for (const canvas of document.querySelectorAll<HTMLCanvasElement>(".timeline-container canvas")) { + const bounds = canvas.getBoundingClientRect(); + if (x < bounds.left || x >= bounds.right || y < bounds.top || y >= bounds.bottom) continue; + const context = canvas.getContext("2d"); + if (!context || bounds.width <= 0 || bounds.height <= 0) continue; + const scaleX = canvas.width / bounds.width; + const scaleY = canvas.height / bounds.height; + const canvasX = Math.round((x - bounds.left) * scaleX); + const canvasY = Math.round((y - bounds.top) * scaleY); + const pixelRadiusX = Math.max(1, Math.round(r * scaleX)); + const pixelRadiusY = Math.max(1, Math.round(r * scaleY)); + const left = Math.max(0, canvasX - pixelRadiusX); + const top = Math.max(0, canvasY - pixelRadiusY); + const width = Math.min(canvas.width - left, pixelRadiusX * 2 + 1); + const height = Math.min(canvas.height - top, pixelRadiusY * 2 + 1); + if (width <= 0 || height <= 0) continue; + const bytes = context.getImageData(left, top, width, height).data; + sampled = true; + for (const byte of bytes) { + hash ^= byte; + hash = Math.imul(hash, 16777619); + } + } + if (!sampled) throw new Error(`No Timeline canvas covers (${x}, ${y})`); + return hash >>> 0; + }, { x: clientX, y: clientY, r: radius }); +} + +test("real automation lane owns point drag, Delete, and atomic undo", async ({ page }) => { + await page.goto("/"); + await page.evaluate((settingsKey) => { + localStorage.removeItem(settingsKey); + localStorage.removeItem("openstudio_essentialControlsDismissed"); + }, PROFILE_SETTINGS_KEY); + await page.reload(); + + await page.getByLabel("Keyboard profile").selectOption("ableton_live"); + await page.getByLabel("Mouse & scroll profile").selectOption("ableton_live"); + await page.getByRole("button", { name: "Use these profiles" }).click(); + await page.getByRole("button", { name: "Add new audio track" }).click(); + + await page.getByRole("button", { name: "Open automation panel", exact: true }).click(); + const dialog = page.getByRole("dialog").filter({ hasText: "Envelopes" }); + await expect(dialog).toBeVisible(); + const volumeRow = dialog.getByText("Volume", { exact: true }).locator(".."); + await volumeRow.getByTitle("Show envelope").click(); + await expect(volumeRow.getByTitle("Hide envelope")).toBeVisible(); + await dialog.getByRole("button", { name: "Close modal" }).click(); + await expect(dialog).toBeHidden(); + await page.locator(".timeline-container").click({ position: { x: 180, y: 70 } }); + await page.keyboard.press("a"); + + const laneHeader = page.locator("[data-automation-lane-id]").first(); + await expect(laneHeader).toBeVisible(); + const laneBounds = await laneHeader.boundingBox(); + const timelineBounds = await page.locator(".timeline-container").boundingBox(); + expect(laneBounds).not.toBeNull(); + expect(timelineBounds).not.toBeNull(); + const pointX = timelineBounds!.x + Math.min(280, timelineBounds!.width * 0.35); + const pointY = laneBounds!.y + laneBounds!.height * 0.55; + + const emptyHash = await timelineCanvasPatchHash(page, pointX, pointY); + await page.mouse.move(pointX, pointY); + await page.mouse.down(); + await page.mouse.up(); + await expect.poll(() => timelineCanvasPatchHash(page, pointX, pointY)) + .not.toBe(emptyHash); + + const originalPointHash = await timelineCanvasPatchHash(page, pointX, pointY); + const movedX = pointX + 64; + const movedY = pointY - 12; + const emptyDestinationHash = await timelineCanvasPatchHash(page, movedX, movedY); + await page.mouse.move(pointX, pointY); + await page.mouse.down(); + await page.mouse.move(movedX, movedY, { steps: 8 }); + await page.mouse.up(); + await expect.poll(() => timelineCanvasPatchHash(page, movedX, movedY)) + .not.toBe(emptyDestinationHash); + await expect.poll(() => timelineCanvasPatchHash(page, pointX, pointY)) + .not.toBe(originalPointHash); + const movedOriginHash = await timelineCanvasPatchHash(page, pointX, pointY); + const movedDestinationHash = await timelineCanvasPatchHash(page, movedX, movedY); + + await page.keyboard.press(`${PRIMARY_KEY}+Z`); + await expect.poll(() => timelineCanvasPatchHash(page, pointX, pointY)) + .not.toBe(movedOriginHash); + await expect.poll(() => timelineCanvasPatchHash(page, movedX, movedY)) + .not.toBe(movedDestinationHash); + + // The point drag activated the real automation shortcut context. Ableton's + // Tab selects the next point and Delete removes that selected point. + await page.keyboard.press("Tab"); + const selectedHash = await timelineCanvasPatchHash(page, pointX, pointY); + await page.keyboard.press("Delete"); + await expect.poll(() => timelineCanvasPatchHash(page, pointX, pointY)) + .not.toBe(selectedHash); + const deletedHash = await timelineCanvasPatchHash(page, pointX, pointY); + + await page.keyboard.press(`${PRIMARY_KEY}+Z`); + await expect.poll(() => timelineCanvasPatchHash(page, pointX, pointY)) + .not.toBe(deletedHash); +}); + +test("real MIDI track automation lane creates, deletes, and restores a pitch-bend point", async ({ page }) => { + await page.goto("/"); + await page.evaluate((settingsKey) => { + localStorage.removeItem(settingsKey); + localStorage.removeItem("openstudio_essentialControlsDismissed"); + }, PROFILE_SETTINGS_KEY); + await page.reload(); + + await page.getByLabel("Keyboard profile").selectOption("ableton_live"); + await page.getByLabel("Mouse & scroll profile").selectOption("ableton_live"); + await page.getByRole("button", { name: "Use these profiles" }).click(); + await page.getByRole("menuitem", { name: "Insert menu" }).click(); + await page.getByRole("menuitem", { name: /New MIDI Track/ }).click(); + + await page.getByRole("button", { name: "Open automation panel", exact: true }).click(); + const dialog = page.getByRole("dialog").filter({ hasText: "Envelopes" }); + await expect(dialog).toBeVisible(); + const pitchBendRow = dialog.getByText("MIDI Pitch Bend", { exact: true }).locator(".."); + await pitchBendRow.getByTitle("Show envelope").click(); + await expect(pitchBendRow.getByTitle("Hide envelope")).toBeVisible(); + await dialog.getByRole("button", { name: "Close modal" }).click(); + + await page.locator(".timeline-container").click({ position: { x: 180, y: 70 } }); + await page.keyboard.press("a"); + const laneHeader = page.locator("[data-automation-lane-id]").first(); + await expect(laneHeader).toBeVisible(); + const laneBounds = await laneHeader.boundingBox(); + const timelineBounds = await page.locator(".timeline-container").boundingBox(); + expect(laneBounds).not.toBeNull(); + expect(timelineBounds).not.toBeNull(); + const pointX = timelineBounds!.x + Math.min(300, timelineBounds!.width * 0.38); + const pointY = laneBounds!.y + laneBounds!.height * 0.42; + + const beforeCreate = await timelineCanvasPatchHash(page, pointX, pointY); + await page.mouse.click(pointX, pointY); + await expect.poll(() => timelineCanvasPatchHash(page, pointX, pointY)).not.toBe(beforeCreate); + + await page.keyboard.press("Tab"); + const beforeDelete = await timelineCanvasPatchHash(page, pointX, pointY); + await page.keyboard.press("Delete"); + await expect.poll(() => timelineCanvasPatchHash(page, pointX, pointY)).not.toBe(beforeDelete); + const deleted = await timelineCanvasPatchHash(page, pointX, pointY); + await page.keyboard.press(`${PRIMARY_KEY}+Z`); + await expect.poll(() => timelineCanvasPatchHash(page, pointX, pointY)).not.toBe(deleted); +}); + +test("Space stops an active recording instead of leaving transport paused", async ({ page }) => { + await page.goto("/"); + await page.evaluate((settingsKey) => { + localStorage.removeItem(settingsKey); + localStorage.removeItem("openstudio_essentialControlsDismissed"); + }, PROFILE_SETTINGS_KEY); + await page.reload(); + + await page.getByLabel("Keyboard profile").selectOption("openstudio"); + await page.getByLabel("Mouse & scroll profile").selectOption("openstudio"); + await page.getByRole("button", { name: "Use these profiles" }).click(); + await page.getByRole("button", { name: "Add new audio track" }).click(); + await page.getByRole("button", { name: "Arm track for recording", exact: true }).click(); + await page.getByRole("contentinfo", { name: "Transport controls" }) + .getByRole("button", { name: "Record", exact: true }) + .click(); + + const recordingStatus = page.getByLabel("Transport status: Recording"); + await expect(recordingStatus).toBeVisible(); + + // Give the Timeline ownership, matching the reported real-app path that + // previously routed Space through transport.play -> pause(). + await page.locator(".timeline-container").click({ position: { x: 180, y: 70 } }); + await page.keyboard.press("Space"); + + await expect(page.getByLabel("Transport status: Stopped")).toBeVisible(); + await expect(recordingStatus).toBeHidden(); +}); diff --git a/frontend/e2e/hotkey-focus-regression.spec.ts b/frontend/e2e/hotkey-focus-regression.spec.ts new file mode 100644 index 0000000..4e2cb19 --- /dev/null +++ b/frontend/e2e/hotkey-focus-regression.spec.ts @@ -0,0 +1,145 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function setShortcutState( + page: Page, + options: { + customPlay?: string; + playing?: boolean; + recording?: boolean; + }, +) { + await page.evaluate(async (next) => { + const { useDAWStore } = await import("/src/store/useDAWStore.ts"); + const current = useDAWStore.getState(); + useDAWStore.setState({ + customShortcuts: next.customPlay === undefined + ? {} + : { + "transport.play": { + common: next.customPlay ? [next.customPlay] : [], + }, + }, + transport: { + ...current.transport, + isPlaying: Boolean(next.playing || next.recording), + isPaused: false, + isRecording: Boolean(next.recording), + }, + recordSession: next.recording + ? { id: "e2e-focus-recording", startTime: 0, trackIds: [] } + : null, + }); + }, options); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/shortcut-e2e.html"); + await expect(page.getByRole("heading", { name: "Shortcut and wheel test harness" })) + .toBeVisible(); + await page.getByLabel("Active shortcut binding").selectOption(""); +}); + +test("window reactivation cannot leave a button owning transport Space", async ({ page, context }) => { + const button = page.getByRole("button", { name: "Native button" }); + await button.focus(); + + const otherPage = await context.newPage(); + await otherPage.goto("about:blank"); + await otherPage.bringToFront(); + await page.bringToFront(); + + await expect.poll(() => page.evaluate(() => ({ + id: (document.activeElement as HTMLElement | null)?.id ?? "", + focused: document.hasFocus(), + }))).toEqual({ id: "native-button", focused: true }); + + // Headless Chromium can report document focus one task before its keyboard + // target settles after a page switch. Reasserting the foreground page keeps + // this test about retained DOM focus instead of a CDP activation race. + await page.bringToFront(); + await page.keyboard.press("Space"); + + await expect(page.locator("#button-click-count")).toHaveText("0"); + await expect(page.getByLabel("Last shortcut result")).toContainText('"owner":"registry"'); + await expect(page.getByLabel("Last shortcut result")).toContainText('"actionId":"transport.play"'); + await otherPage.close(); +}); + +test("focused slider yields active-profile Space to transport", async ({ page }) => { + const slider = page.getByRole("slider", { name: "Native range" }); + await slider.focus(); + await page.keyboard.press("Space"); + + await expect(slider).toHaveValue("5"); + await expect(page.getByLabel("Last shortcut result")).toContainText('"actionId":"transport.play"'); +}); + +test("held Space is consumed but invokes transport exactly once", async ({ page }) => { + await page.evaluate(async () => { + const { useDAWStore } = await import("/src/store/useDAWStore.ts"); + const pageGlobal = window as Window & { __hotkeyPlayCalls: number }; + pageGlobal.__hotkeyPlayCalls = 0; + useDAWStore.setState({ + play: async () => { + pageGlobal.__hotkeyPlayCalls += 1; + }, + }); + }); + await page.getByRole("button", { name: "Native button" }).focus(); + + await page.keyboard.down("Space"); + await page.keyboard.down("Space"); + await page.keyboard.up("Space"); + + await expect.poll(() => page.evaluate(() => ( + window as Window & { __hotkeyPlayCalls: number } + ).__hotkeyPlayCalls)).toBe(1); + await expect(page.locator("#button-click-count")).toHaveText("0"); +}); + +test("explicitly unbound Play returns Space to the focused button", async ({ page }) => { + await setShortcutState(page, { customPlay: "" }); + const button = page.getByRole("button", { name: "Native button" }); + await button.focus(); + await page.keyboard.press("Space"); + + await expect(page.locator("#button-click-count")).toHaveText("1"); + await expect(page.getByLabel("Last shortcut result")).toContainText('"owner":"native"'); +}); + +test("remapped Play works from a focused button without reserving Space", async ({ page }) => { + await setShortcutState(page, { customPlay: "P" }); + const button = page.getByRole("button", { name: "Native button" }); + await button.focus(); + + await page.keyboard.press("p"); + await expect(page.locator("#button-click-count")).toHaveText("0"); + await expect(page.getByLabel("Last shortcut result")).toContainText('"actionId":"transport.play"'); + + await page.keyboard.press("Space"); + await expect(page.locator("#button-click-count")).toHaveText("1"); + await expect(page.getByLabel("Last shortcut result")).toContainText('"owner":"native"'); +}); + +test("editable Space types while stopped and stops active transport", async ({ page }) => { + const input = page.getByRole("textbox", { name: "Text input" }); + await input.focus(); + await input.evaluate((element) => { + const textInput = element as HTMLInputElement; + textInput.setSelectionRange(textInput.value.length, textInput.value.length); + }); + await page.keyboard.press("Space"); + await expect(input).toHaveValue("select this text "); + await expect(page.getByLabel("Last shortcut result")).toContainText('"owner":"native"'); + + await setShortcutState(page, { playing: true }); + await input.focus(); + await input.evaluate((element) => { + const textInput = element as HTMLInputElement; + textInput.setSelectionRange(textInput.value.length, textInput.value.length); + }); + await page.keyboard.press("Space"); + + await expect(input).toHaveValue("select this text "); + await expect(page.getByLabel("Last shortcut result")).toContainText('"actionId":"transport.play"'); +}); diff --git a/frontend/e2e/input-profile-exhaustive.spec.ts b/frontend/e2e/input-profile-exhaustive.spec.ts new file mode 100644 index 0000000..d3c067c --- /dev/null +++ b/frontend/e2e/input-profile-exhaustive.spec.ts @@ -0,0 +1,265 @@ +import { expect, test, type Page } from "@playwright/test"; + +type ProfileId = + | "openstudio" + | "pro_tools" + | "cubase" + | "reaper" + | "audacity" + | "logic_pro" + | "fl_studio" + | "ableton_live" + | "studio_one" + | "bitwig_studio" + | "reason" + | "cakewalk_sonar" + | "garageband" + | "digital_performer" + | "ardour" + | "adobe_audition" + | "mixcraft" + | "waveform" + | "renoise"; + +interface KeyInit { + key: string; + code: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} + +interface WheelInit { + deltaY: number; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} + +interface ProfileRuntimeCase { + id: ProfileId; + platform: "macos" | "windows"; + keyboard: { + target: "application-surface" | "timeline-surface" | "piano-surface"; + actionId: string | null; + event: KeyInit; + }; + wheel: { + target: string; + ruleId: string | null; + event: WheelInit; + }; + pointer: { + action: string; + event: Omit<KeyInit, "key" | "code">; + }; +} + +const cases: readonly ProfileRuntimeCase[] = [ + { + id: "openstudio", + platform: "windows", + keyboard: { target: "application-surface", actionId: "view.toggleMixer", event: { key: "m", code: "KeyM", ctrlKey: true } }, + wheel: { target: "wheel-timeline", ruleId: "timeline.horizontal-zoom", event: { deltaY: -120, ctrlKey: true } }, + pointer: { action: "bypass_snap", event: { altKey: true } }, + }, + { + id: "pro_tools", + platform: "windows", + keyboard: { target: "application-surface", actionId: "view.toggleMixer", event: { key: "=", code: "Equal", ctrlKey: true } }, + wheel: { target: "wheel-timeline", ruleId: "pro-tools.option-horizontal-zoom", event: { deltaY: -120, altKey: true } }, + pointer: { action: "copy", event: { altKey: true } }, + }, + { + id: "cubase", + platform: "windows", + keyboard: { target: "application-surface", actionId: "view.toggleMixer", event: { key: "F3", code: "F3" } }, + wheel: { target: "wheel-timeline", ruleId: "cubase.horizontal-zoom", event: { deltaY: -120, ctrlKey: true } }, + pointer: { action: "copy", event: { altKey: true } }, + }, + { + id: "reaper", + platform: "windows", + keyboard: { target: "application-surface", actionId: "view.toggleMixer", event: { key: "m", code: "KeyM", ctrlKey: true } }, + wheel: { target: "wheel-timeline", ruleId: "reaper.horizontal-zoom", event: { deltaY: -120 } }, + pointer: { action: "copy", event: { ctrlKey: true } }, + }, + { + id: "audacity", + platform: "windows", + keyboard: { target: "timeline-surface", actionId: "view.zoomIn", event: { key: "1", code: "Digit1", ctrlKey: true } }, + wheel: { target: "wheel-timeline", ruleId: "audacity.horizontal-zoom", event: { deltaY: -120, ctrlKey: true } }, + pointer: { action: "none", event: { altKey: true } }, + }, + { + id: "logic_pro", + platform: "macos", + keyboard: { target: "application-surface", actionId: "view.toggleMixer", event: { key: "x", code: "KeyX" } }, + wheel: { target: "wheel-timeline", ruleId: "logic-pro.control-option-horizontal-zoom", event: { deltaY: -120, ctrlKey: true, altKey: true } }, + pointer: { action: "copy", event: { altKey: true } }, + }, + { + id: "fl_studio", + platform: "windows", + keyboard: { target: "application-surface", actionId: "view.toggleMixer", event: { key: "F9", code: "F9" } }, + wheel: { target: "wheel-track", ruleId: "fl-studio.playlist-track-reorder", event: { deltaY: -120, shiftKey: true } }, + pointer: { action: "none", event: { altKey: true } }, + }, + { + id: "ableton_live", + platform: "windows", + keyboard: { target: "application-surface", actionId: "view.toggleMixer", event: { key: "m", code: "KeyM", ctrlKey: true, altKey: true } }, + wheel: { target: "wheel-timeline", ruleId: "ableton-live.horizontal-zoom", event: { deltaY: -120, ctrlKey: true } }, + pointer: { action: "copy", event: { ctrlKey: true } }, + }, + { + id: "studio_one", + platform: "windows", + keyboard: { target: "application-surface", actionId: "view.toggleMixer", event: { key: "F3", code: "F3" } }, + wheel: { target: "wheel-timeline", ruleId: "studio-one.horizontal-zoom", event: { deltaY: -120, ctrlKey: true, shiftKey: true } }, + pointer: { action: "copy", event: { altKey: true } }, + }, + { + id: "bitwig_studio", + platform: "windows", + keyboard: { target: "timeline-surface", actionId: "view.zoomIn", event: { key: "=", code: "Equal", ctrlKey: true } }, + wheel: { target: "wheel-timeline", ruleId: "bitwig-studio.control-alt-horizontal-zoom", event: { deltaY: -120, ctrlKey: true, altKey: true } }, + pointer: { action: "none", event: { altKey: true } }, + }, + { + id: "reason", + platform: "windows", + keyboard: { target: "timeline-surface", actionId: "view.zoomIn", event: { key: "h", code: "KeyH" } }, + wheel: { target: "wheel-timeline", ruleId: "reason.horizontal-zoom", event: { deltaY: -120, ctrlKey: true } }, + pointer: { action: "copy", event: { ctrlKey: true } }, + }, + { + id: "cakewalk_sonar", + platform: "windows", + keyboard: { target: "application-surface", actionId: "view.toggleMixer", event: { key: "2", code: "Digit2", altKey: true } }, + wheel: { target: "wheel-timeline", ruleId: "cakewalk-sonar.alt-horizontal-zoom", event: { deltaY: -120, altKey: true } }, + pointer: { action: "none", event: { altKey: true } }, + }, + { + id: "garageband", + platform: "macos", + keyboard: { target: "application-surface", actionId: "view.toggleMasterTrackTCP", event: { key: "m", code: "KeyM", metaKey: true, shiftKey: true } }, + wheel: { target: "wheel-timeline", ruleId: null, event: { deltaY: -120 } }, + pointer: { action: "none", event: { altKey: true } }, + }, + { + id: "digital_performer", + platform: "macos", + // DP's official command system is user-assignable and does not publish a + // stable default table for this profile. Strict mode must not leak the + // OpenStudio Command+M mixer binding into it. + keyboard: { target: "application-surface", actionId: null, event: { key: "m", code: "KeyM", metaKey: true } }, + wheel: { target: "wheel-timeline", ruleId: "digital-performer.option-horizontal-zoom", event: { deltaY: -120, altKey: true } }, + pointer: { action: "none", event: { altKey: true } }, + }, + { + id: "ardour", + platform: "windows", + keyboard: { target: "timeline-surface", actionId: "view.zoomToSelection", event: { key: "z", code: "KeyZ" } }, + wheel: { target: "wheel-timeline", ruleId: "ardour.horizontal-zoom", event: { deltaY: -120, ctrlKey: true } }, + pointer: { action: "none", event: { altKey: true } }, + }, + { + id: "adobe_audition", + platform: "windows", + keyboard: { target: "timeline-surface", actionId: "view.zoomIn", event: { key: "=", code: "Equal" } }, + wheel: { target: "wheel-ruler", ruleId: "adobe-audition.ruler-horizontal-zoom", event: { deltaY: -120 } }, + pointer: { action: "copy", event: { altKey: true } }, + }, + { + id: "mixcraft", + platform: "windows", + keyboard: { target: "application-surface", actionId: "view.toggleVirtualKeyboard", event: { key: "k", code: "KeyK", ctrlKey: true, altKey: true } }, + wheel: { target: "wheel-timeline", ruleId: "mixcraft.horizontal-zoom", event: { deltaY: -120 } }, + pointer: { action: "copy", event: { altKey: true } }, + }, + { + id: "waveform", + platform: "windows", + keyboard: { target: "timeline-surface", actionId: "view.zoomToFit", event: { key: "F8", code: "F8" } }, + wheel: { target: "wheel-timeline", ruleId: "waveform.horizontal-zoom", event: { deltaY: -120 } }, + pointer: { action: "none", event: { altKey: true } }, + }, + { + id: "renoise", + platform: "windows", + keyboard: { target: "application-surface", actionId: "transport.play", event: { key: " ", code: "Space" } }, + wheel: { target: "wheel-timeline", ruleId: null, event: { deltaY: -120 } }, + pointer: { action: "none", event: { altKey: true } }, + }, +] as const; + +async function readJsonOutput(page: Page, label: string): Promise<Record<string, unknown>> { + const raw = await page.getByLabel(label).textContent(); + if (!raw) throw new Error(`No JSON in ${label}`); + return JSON.parse(raw) as Record<string, unknown>; +} + +for (const entry of cases) { + test(`${entry.id} dispatches its documented key policy, wheel, and pointer event`, async ({ page }) => { + await page.goto("/shortcut-e2e.html"); + await expect(page.getByRole("heading", { name: "Shortcut and wheel test harness" })).toBeVisible(); + await page.getByLabel("Active shortcut binding").selectOption(""); + await page.getByLabel("Keyboard platform").selectOption(entry.platform); + await page.getByLabel("Wheel platform").selectOption(entry.platform); + await page.getByLabel("Harness keyboard profile").selectOption(entry.id); + await page.getByLabel("Mouse profile").selectOption(entry.id); + + await page.evaluate(({ target, event }) => { + window.dispatchHarnessKey(target, event); + }, { target: entry.keyboard.target, event: entry.keyboard.event }); + if (entry.keyboard.actionId === null) { + await expect.poll(async () => ( + (await readJsonOutput(page, "Last shortcut result")).handled + )).toBe(false); + expect(await readJsonOutput(page, "Last shortcut result")).toMatchObject({ + handled: false, + }); + } else { + await expect.poll(async () => ( + (await readJsonOutput(page, "Last shortcut result")).actionId + )).toBe(entry.keyboard.actionId); + expect(await readJsonOutput(page, "Last shortcut result")).toMatchObject({ + handled: true, + owner: "registry", + actionId: entry.keyboard.actionId, + platform: entry.platform, + }); + } + + const wheelDispatch = await page.evaluate(({ target, event }) => { + const dispatch = window.dispatchHarnessWheel(target, event); + const raw = document.getElementById(target)?.dataset.lastWheelResult; + if (!raw) throw new Error(`No wheel result for ${target}`); + return { dispatch, resolved: JSON.parse(raw) as Record<string, unknown> }; + }, { target: entry.wheel.target, event: entry.wheel.event }); + expect(wheelDispatch.resolved).toMatchObject({ + profileId: entry.id, + ruleId: entry.wheel.ruleId, + matched: entry.wheel.ruleId !== null, + preventDefault: entry.wheel.ruleId !== null, + }); + expect(wheelDispatch.dispatch.defaultPrevented).toBe(entry.wheel.ruleId !== null); + + const pointerDispatch = await page.evaluate((event) => { + const dispatch = window.dispatchHarnessPointer("pointer-clip-drag", event); + const raw = document.getElementById("pointer-clip-drag")?.dataset.lastPointerResult; + if (!raw) throw new Error("No pointer result"); + return { dispatch, resolved: JSON.parse(raw) as Record<string, unknown> }; + }, entry.pointer.event); + expect(pointerDispatch.resolved).toMatchObject({ + profileId: entry.id, + context: "clip_drag", + action: entry.pointer.action, + source: "profile", + }); + expect(pointerDispatch.dispatch.defaultPrevented).toBe(entry.pointer.action !== "none"); + }); +} diff --git a/frontend/e2e/input-profile-wheel.spec.ts b/frontend/e2e/input-profile-wheel.spec.ts new file mode 100644 index 0000000..2259450 --- /dev/null +++ b/frontend/e2e/input-profile-wheel.spec.ts @@ -0,0 +1,210 @@ +import { expect, test, type Page } from "@playwright/test"; + +interface SyntheticWheelOptions { + deltaX?: number; + deltaY?: number; + deltaMode?: number; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + clientX?: number; + clientY?: number; +} + +async function dispatchRawWheel( + page: Page, + targetId: string, + options: SyntheticWheelOptions, +): Promise<{ dispatchReturned: boolean; defaultPrevented: boolean }> { + return page.evaluate( + ({ id, init }) => window.dispatchHarnessWheel(id, init), + { id: targetId, init: options }, + ); +} + +async function dispatchResolvedWheel( + page: Page, + targetId: string, + options: SyntheticWheelOptions, +): Promise<{ + dispatch: { dispatchReturned: boolean; defaultPrevented: boolean }; + resolved: Record<string, unknown>; +}> { + return page.evaluate(({ id, init }) => { + const dispatch = window.dispatchHarnessWheel(id, init); + const raw = document.getElementById(id)?.dataset.lastWheelResult; + if (!raw) throw new Error(`No wheel result for ${id}`); + return { dispatch, resolved: JSON.parse(raw) as Record<string, unknown> }; + }, { id: targetId, init: options }); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/shortcut-e2e.html"); + await expect(page.getByRole("heading", { name: "Shortcut and wheel test harness" })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Timeline nested profiled range" })).toBeVisible(); +}); + +test("nested profiled controls own plain, fine, and suppressed wheel gestures", async ({ page }) => { + await page.getByLabel("Mouse profile").selectOption("cubase"); + const timelineValue = page.getByLabel("Timeline nested profiled range value"); + const pianoValue = page.getByLabel("Piano roll nested profiled range value"); + + await dispatchRawWheel(page, "timeline-profiled-range", { deltaY: -100 }); + await expect(timelineValue).toHaveText("0.600"); + + await dispatchRawWheel(page, "timeline-profiled-range", { + deltaY: -100, + shiftKey: true, + }); + await expect(timelineValue).toHaveText("0.610"); + + await dispatchRawWheel(page, "timeline-profiled-range", { + deltaY: -100, + altKey: true, + }); + await expect(timelineValue).toHaveText("0.610"); + + await dispatchRawWheel(page, "piano-profiled-range", { deltaY: -100 }); + await expect(pianoValue).toHaveText("0.600"); + await expect(page.getByLabel("Viewport wheel hit counts")).toHaveText( + '{"timeline":0,"piano_roll":0}', + ); +}); + +test("high-resolution packets accumulate before one profiled edit burst", async ({ page }) => { + await page.getByLabel("Mouse profile").selectOption("cubase"); + const value = page.getByLabel("Timeline nested profiled range value"); + const begins = page.getByLabel("Timeline nested profiled range begin count"); + const commits = page.getByLabel("Timeline nested profiled range commit count"); + + for (let packet = 0; packet < 3; packet += 1) { + await dispatchRawWheel(page, "timeline-profiled-range", { deltaY: -0.25 }); + await expect(value).toHaveText("0.500"); + await expect(begins).toHaveText("0"); + } + + await dispatchRawWheel(page, "timeline-profiled-range", { deltaY: -0.25 }); + await expect(value).toHaveText("0.501"); + await expect(begins).toHaveText("1"); + await expect.poll(async () => commits.textContent()).toBe("1"); + await expect(page.getByLabel("Viewport wheel hit counts")).toHaveText( + '{"timeline":0,"piano_roll":0}', + ); +}); + +test("macOS physical Control and Command are both blocked by the raw browser guard", async ({ page }) => { + await page.getByLabel("Wheel platform").selectOption("macos"); + + const physicalControl = await dispatchResolvedWheel(page, "wheel-browser", { + deltaY: 12, + ctrlKey: true, + }); + expect(physicalControl.resolved).toMatchObject({ + ruleId: "browser.native-scroll", + eventDefaultPrevented: true, + }); + expect(physicalControl.dispatch).toEqual({ dispatchReturned: false, defaultPrevented: true }); + + const command = await dispatchResolvedWheel(page, "wheel-browser", { + deltaY: 12, + metaKey: true, + }); + expect(command.resolved).toMatchObject({ + ruleId: "browser.suppress-browser-zoom", + eventDefaultPrevented: true, + }); + expect(command.dispatch).toEqual({ dispatchReturned: false, defaultPrevented: true }); + + const both = await dispatchResolvedWheel(page, "wheel-browser", { + deltaY: 12, + ctrlKey: true, + metaKey: true, + }); + expect(both.resolved).toMatchObject({ + ruleId: "browser.suppress-browser-zoom", + eventDefaultPrevented: true, + }); + expect(both.dispatch).toEqual({ dispatchReturned: false, defaultPrevented: true }); +}); + +test("exact DAW editor subtargets preserve their target and anchor", async ({ page }) => { + await page.getByLabel("Mouse profile").selectOption("pro_tools"); + await page.getByLabel("Wheel platform").selectOption("macos"); + const piano = await dispatchResolvedWheel(page, "wheel-piano", { + deltaY: -8, + ctrlKey: true, + altKey: true, + clientX: 141, + clientY: 73, + }); + expect(piano.resolved).toMatchObject({ + profileId: "pro_tools", + ruleId: "pro-tools.midi-note-height", + operation: "zoom", + target: "midi-note-height", + anchor: { + kind: "pointer", + clientX: 141, + clientY: 73, + targetId: "wheel-piano", + }, + }); + + await page.getByLabel("Mouse profile").selectOption("audacity"); + await page.getByLabel("Wheel platform").selectOption("windows"); + const waveformScale = await dispatchResolvedWheel(page, "wheel-waveform-scale", { + deltaY: 7, + ctrlKey: true, + clientX: 52, + clientY: 118, + }); + expect(waveformScale.resolved).toMatchObject({ + profileId: "audacity", + ruleId: "audacity.waveform-scale-zoom", + operation: "zoom", + target: "waveform-scale", + anchor: { + kind: "hovered-track", + clientX: 52, + clientY: 118, + targetId: "track-e2e", + }, + }); +}); + +test("browser-integrated detached snapshots validate before updating the live mouse profile", async ({ page }) => { + const invalid = await page.evaluate(() => window.applyHarnessInputProfileSnapshot({ + keyboardShortcutProfileId: "reaper", + mouseBehaviorProfileId: "not-a-profile", + customKeyboardProfiles: [], + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + })); + expect(invalid).toEqual({ + applied: false, + keyboardShortcutProfileId: "openstudio", + mouseBehaviorProfileId: "openstudio", + }); + + const applied = await page.evaluate(() => window.applyHarnessInputProfileSnapshot({ + keyboardShortcutProfileId: "reaper", + mouseBehaviorProfileId: "cubase", + customKeyboardProfiles: [], + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + })); + expect(applied).toEqual({ + applied: true, + keyboardShortcutProfileId: "reaper", + mouseBehaviorProfileId: "cubase", + }); + await expect(page.getByLabel("Mouse profile")).toHaveValue("cubase"); + await expect(page.getByLabel("Profile snapshot result")).toContainText('"applied":true'); + + await dispatchRawWheel(page, "timeline-profiled-range", { + deltaY: -100, + altKey: true, + }); + await expect(page.getByLabel("Timeline nested profiled range value")).toHaveText("0.500"); +}); diff --git a/frontend/e2e/midi-input-meter.spec.ts b/frontend/e2e/midi-input-meter.spec.ts new file mode 100644 index 0000000..596c707 --- /dev/null +++ b/frontend/e2e/midi-input-meter.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/shortcut-e2e.html"); + await expect(page.getByRole("meter", { name: "Harness track meter" })).toBeVisible(); +}); + +test("armed raw MIDI draws mirrored light input lanes and audio output takes precedence", async ({ page }) => { + await page.evaluate(() => window.setHarnessMeterState({ + audioLevel: 0, + midiInputLevel: 0.75, + armed: true, + trackType: "midi", + })); + + const meter = page.getByRole("meter", { name: "Harness track meter" }); + await expect(meter).toHaveAttribute("data-meter-source", "midi_input"); + await page.waitForTimeout(120); + + const pixels = await meter.evaluate((canvas) => { + const element = canvas as HTMLCanvasElement; + const context = element.getContext("2d"); + if (!context) throw new Error("Meter canvas has no 2D context"); + const y = element.height - 8; + return { + left: Array.from(context.getImageData(2, y, 1, 1).data), + right: Array.from(context.getImageData(13, y, 1, 1).data), + }; + }); + expect(pixels.left).toEqual([103, 232, 249, 255]); + expect(pixels.right).toEqual(pixels.left); + + await page.evaluate(() => window.setHarnessMeterState({ + audioLevel: 0.2, + midiInputLevel: 1, + armed: true, + trackType: "instrument", + })); + await expect(meter).toHaveAttribute("data-meter-source", "audio"); + + await page.evaluate(() => window.setHarnessMeterState({ + audioLevel: 0, + midiInputLevel: 1, + armed: false, + trackType: "midi", + })); + await expect(meter).toHaveAttribute("data-meter-source", "idle"); +}); diff --git a/frontend/e2e/nam-multi-capture-lifecycle.spec.ts b/frontend/e2e/nam-multi-capture-lifecycle.spec.ts new file mode 100644 index 0000000..3bb5b7e --- /dev/null +++ b/frontend/e2e/nam-multi-capture-lifecycle.spec.ts @@ -0,0 +1,217 @@ +import { expect, test, type Page } from "@playwright/test"; + +const NAM_ADDRESS = { + trackId: "nam-multi-capture-e2e", + chain: "track", + fxIndex: 0, +} as const; + +const PACK_TITLE = "Headbangers Ball Amp Pack IR/RAW"; + +function sourceFlowUrl() { + const session = { + address: NAM_ADDRESS, + title: "OpenStudio NAM Rack", + fallbackName: "OpenStudio NAM Rack", + }; + const params = new URLSearchParams({ + window: "pluginEditor", + platform: "windows", + windowChrome: "native", + mockPlugin: "nam", + sessionId: JSON.stringify(session), + namView: "rack", + namLibraryFlow: "amp", + namQuery: "headbangers", + }); + return `/?${params.toString()}`; +} + +async function readRackState(page: Page) { + return page.evaluate(async (address) => { + const moduleUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ moduleUrl); + return nativeBridge.getBuiltInPluginState(address); + }, NAM_ADDRESS); +} + +async function seedHostTrack(page: Page) { + await page.evaluate(async (trackId) => { + const moduleUrl = "/src/store/useDAWStore.ts"; + const { createDefaultTrack, useDAWStore } = await import(/* @vite-ignore */ moduleUrl); + useDAWStore.setState({ + tracks: [createDefaultTrack(trackId, "NAM audition", "#335577", "audio", [])], + isModified: false, + canUndo: false, + canRedo: false, + }); + }, NAM_ADDRESS.trackId); +} + +async function readMonitorAndHistory(page: Page) { + return page.evaluate(async (trackId) => { + const moduleUrl = "/src/store/useDAWStore.ts"; + const { useDAWStore } = await import(/* @vite-ignore */ moduleUrl); + const state = useDAWStore.getState(); + return { + monitorEnabled: state.tracks.find((track: { id: string }) => track.id === trackId)?.monitorEnabled, + isModified: state.isModified, + canUndo: state.canUndo, + canRedo: state.canRedo, + }; + }, NAM_ADDRESS.trackId); +} + +async function readAmpPath(page: Page) { + const state = await readRackState(page); + return String(state.modelState?.ampModelPath ?? "").replace(/\\/g, "/"); +} + +async function expectAmpPath(page: Page, modelId: number | null) { + const modelSlug = modelId === null ? "" : ({ + 6713901: "headbangers-ball-01-raw.nam", + 6713902: "headbangers-ball-01-ir.nam", + 6713904: "headbangers-ball-02-ir.nam", + } as Record<number, string>)[modelId]; + await expect.poll(() => readAmpPath(page)) + .toEqual(modelId === null ? "" : expect.stringMatching(new RegExp(`/${modelSlug.replace(".", "\\.")}$`))); +} + +test("multi-capture pack supports preview, use, replace, bypass, and unload", async ({ page }) => { + await page.goto(sourceFlowUrl()); + await seedHostTrack(page); + await expect.poll(() => readMonitorAndHistory(page)).toEqual({ + monitorEnabled: false, + isModified: false, + canUndo: false, + canRedo: false, + }); + + const packCard = page.locator(".tone-feed-row").filter({ hasText: PACK_TITLE }); + await expect(packCard).toHaveCount(1); + await expect(packCard).toContainText(/4 captures/i); + + const picker = page.locator('[data-qa="nam-tone-capture-picker"]:not([data-compact])'); + await expect(picker).toBeVisible(); + await expect(picker.locator(".nam-tone-capture-select")).toHaveCount(0); + await packCard.getByRole("button", { name: "View 4 Captures" }).click(); + await expect(picker).toContainText("4 captures"); + await expect(picker.locator(".nam-tone-capture-select")).toHaveCount(4); + + const raw01Select = picker.locator(".nam-tone-capture-select").filter({ hasText: "Headbangers Ball 01 RAW" }); + const ir01Select = picker.locator(".nam-tone-capture-select").filter({ hasText: "Headbangers Ball 01 IR" }); + const raw01 = raw01Select.locator(".."); + const ir01 = ir01Select.locator(".."); + await expect(raw01).toContainText("RAW / AMP ONLY"); + await expect(ir01).toContainText("CAB EMBEDDED"); + const baselineAmpPath = await readAmpPath(page); + expect(baselineAmpPath).not.toBe(""); + + await ir01Select.focus(); + await page.keyboard.press("Enter"); + await expect(ir01Select).toHaveAttribute("aria-pressed", "true"); + await expect(ir01).toHaveAttribute("data-selected", "true"); + await expect.poll(() => readAmpPath(page)).toBe(baselineAmpPath); + + await ir01.getByRole("button", { name: "Audition Headbangers Ball 01 IR" }).click(); + await expect(ir01).toHaveAttribute("data-audition", "true"); + await expectAmpPath(page, 6713902); + await expect.poll(() => readMonitorAndHistory(page)).toEqual({ + monitorEnabled: true, + isModified: false, + canUndo: false, + canRedo: false, + }); + + await raw01.getByRole("button", { name: "Audition Headbangers Ball 01 RAW" }).click(); + await expect(raw01).toHaveAttribute("data-audition", "true"); + await expect(ir01).not.toHaveAttribute("data-audition", "true"); + await expectAmpPath(page, 6713901); + await expect.poll(() => readMonitorAndHistory(page)).toEqual({ + monitorEnabled: true, + isModified: false, + canUndo: false, + canRedo: false, + }); + + await raw01.getByRole("button", { name: "Stop auditioning Headbangers Ball 01 RAW" }).click(); + await expect.poll(() => readAmpPath(page)).toBe(baselineAmpPath); + await expect.poll(() => readMonitorAndHistory(page)).toEqual({ + monitorEnabled: false, + isModified: false, + canUndo: false, + canRedo: false, + }); + + await raw01.getByRole("button", { name: "Audition Headbangers Ball 01 RAW" }).click(); + await expectAmpPath(page, 6713901); + await raw01.getByRole("button", { name: "Use Headbangers Ball 01 RAW" }).click(); + await expect.poll(() => readMonitorAndHistory(page)).toEqual({ + monitorEnabled: false, + isModified: false, + canUndo: false, + canRedo: false, + }); + + const nameplate = page.locator('[data-qa="nam-amp-capture-nameplate"]'); + await expect(nameplate).toHaveAttribute("data-state", "loaded"); + await expect(nameplate).toHaveAttribute("data-includes-cab", "false"); + await expect(nameplate).toContainText(/Headbangers Ball 01 RAW/i); + await expectAmpPath(page, 6713901); + + const ampPower = page.locator('[data-param-id="ampEnabled"][role]').first(); + await ampPower.focus(); + await page.keyboard.press("Enter"); + await expect.poll(async () => (await readRackState(page)).values?.ampEnabled).toBe(0); + await page.keyboard.press("Enter"); + await expect.poll(async () => (await readRackState(page)).values?.ampEnabled).toBe(1); + + await page.locator('[data-qa="nam-amp-capture-selector"]').click(); + await expect(picker).toBeVisible(); + const ir02 = picker.locator(".nam-tone-capture-select").filter({ hasText: "Headbangers Ball 02 IR" }).locator(".."); + await ir02.getByRole("button", { name: "Use Headbangers Ball 02 IR" }).click(); + + await expect(nameplate).toHaveAttribute("data-state", "loaded"); + await expect(nameplate).toHaveAttribute("data-includes-cab", "true"); + await expect(nameplate).toContainText(/Headbangers Ball 02 IR/i); + await expectAmpPath(page, 6713904); + await expect.poll(async () => (await readRackState(page)).values?.cabEnabled).toBe(0); + + await page.locator('[data-qa="nam-amp-capture-unload"]').click(); + await expect(nameplate).toHaveAttribute("data-state", "empty"); + await expect(nameplate).toContainText("No amp capture loaded"); + await expectAmpPath(page, null); +}); + +test("audition reports monitor bridge failures without dirtying history or pretending success", async ({ page }) => { + await page.goto(sourceFlowUrl()); + await seedHostTrack(page); + await page.evaluate(async () => { + const moduleUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ moduleUrl); + nativeBridge.setTrackInputMonitoring = async () => false; + }); + + const packCard = page.locator(".tone-feed-row").filter({ hasText: PACK_TITLE }); + await packCard.getByRole("button", { name: "View 4 Captures" }).click(); + const picker = page.locator('[data-qa="nam-tone-capture-picker"]:not([data-compact])'); + const ir01 = picker.locator(".nam-tone-capture-select").filter({ hasText: "Headbangers Ball 01 IR" }).locator(".."); + await ir01.getByRole("button", { name: "Audition Headbangers Ball 01 IR" }).click(); + + await expectAmpPath(page, 6713902); + await expect(page.getByText(/track monitoring could not be enabled automatically/i).first()).toBeVisible(); + await expect.poll(() => readMonitorAndHistory(page)).toEqual({ + monitorEnabled: false, + isModified: false, + canUndo: false, + canRedo: false, + }); + + await ir01.getByRole("button", { name: "Stop auditioning Headbangers Ball 01 IR" }).click(); + await expect.poll(() => readMonitorAndHistory(page)).toEqual({ + monitorEnabled: false, + isModified: false, + canUndo: false, + canRedo: false, + }); +}); diff --git a/frontend/e2e/nam-rack-approved-surfaces.spec.ts b/frontend/e2e/nam-rack-approved-surfaces.spec.ts new file mode 100644 index 0000000..1b35578 --- /dev/null +++ b/frontend/e2e/nam-rack-approved-surfaces.spec.ts @@ -0,0 +1,800 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + faceplateControlHitRect, + faceplateControlVisualRect, + NAM_AMP_V4_FACEPLATE, + NAM_EQ_V4_FACEPLATE, + type FaceplateManifest, +} from "../src/components/namRackFaceplateGeometry"; + +const NAM_ADDRESS = { + trackId: "nam-approved-surface-qa", + chain: "track", + fxIndex: 0, +} as const; + +const PRE_EQ_PARAM_IDS = [ + "preEqEnabled", + "preEq120Db", + "preEq250Db", + "preEq500Db", + "preEq1kDb", + "preEq2k5Db", + "preEq5kDb", + "preEq8kDb", + "preEq12kDb", + "preEqHPFHz", + "preEqLPFHz", +] as const; + +const DRIVE_PARAM_IDS = [ + "precisionDriveEnabled", + "precisionDriveDrive", + "precisionDriveVolumeDb", + "precisionDriveBright", + "precisionDriveAttack", + "precisionDriveGate", +] as const; + +const AMP_PARAM_IDS = [ + "ampEnabled", + "ampBoost", + "ampVoice", + "ampGainDb", + "bassDb", + "midDb", + "trebleDb", + "presenceDb", + "ampMix", + "ampOutputDb", +] as const; + +const POST_EQ_BAND_IDS = [ + "eq65Db", + "eq125Db", + "eq250Db", + "eq500Db", + "eq1kDb", + "eq2kDb", + "eq4kDb", + "eq8kDb", + "eq16kDb", +] as const; + +const POST_EQ_PARAM_IDS = [ + "eqEnabled", + ...POST_EQ_BAND_IDS, + "eqHPFHz", + "eqLevelDb", + "eqLPFHz", +] as const; + +type RackSection = "pre" | "amp" | "eq"; + +function rackUrl(section: RackSection) { + const focus = section === "pre" ? "gate" : section; + const session = { + address: NAM_ADDRESS, + title: "OpenStudio NAM Rack", + fallbackName: "OpenStudio NAM Rack", + }; + const params = new URLSearchParams({ + window: "pluginEditor", + platform: "windows", + windowChrome: "native", + mockPlugin: "nam", + sessionId: JSON.stringify(session), + namView: "rack", + namFocus: focus, + namSection: section, + }); + return `/?${params.toString()}`; +} + +async function openRackSection(page: Page, section: RackSection) { + await page.goto(rackUrl(section)); + const host = page.locator( + `.nam-rack-design-port.nam-native-design-surface[data-design-section="${section}"]`, + ); + // The detached editor is bootstrapped through a dynamic import. On a cold + // Windows CI worker Vite can need longer than Playwright's 5 s assertion + // default to transform the full NAM Rack surface before its host mounts. + await expect(host).toBeVisible({ timeout: 15_000 }); + await expect(host.locator(".nam-rack-artboard")).toBeVisible(); + await page.evaluate(async () => { + if (document.fonts?.ready) await document.fonts.ready; + const images = Array.from( + document.querySelectorAll<HTMLImageElement>( + ".nam-rack-design-port [data-rack-design-asset-kind]", + ), + ); + await Promise.all(images.map(async (image) => { + if (!image.complete) { + await new Promise<void>((resolve) => { + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => resolve(), { once: true }); + }); + } + if (typeof image.decode === "function") await image.decode().catch(() => undefined); + })); + await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + }); + return host; +} + +async function readRackValues(page: Page): Promise<Record<string, number>> { + return page.evaluate(async (address) => { + const moduleUrl = "/src/services/NativeBridge.ts"; + const { nativeBridge } = await import(/* @vite-ignore */ moduleUrl); + const state = await nativeBridge.getBuiltInPluginState(address); + return state?.values ?? {}; + }, NAM_ADDRESS); +} + +async function interactiveParamIds(page: Page, moduleId: string) { + return page.locator(`[data-module="${moduleId}"]`).evaluate((module) => ( + Array.from(module.querySelectorAll<HTMLElement>("[data-param-id][role]")) + .filter((node) => { + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + return rect.width > 0 + && rect.height > 0 + && style.display !== "none" + && style.visibility !== "hidden" + && node.getAttribute("aria-disabled") !== "true"; + }) + .map((node) => node.dataset.paramId ?? "") + .filter(Boolean) + .sort() + )); +} + +async function surfaceGeometryFailures(page: Page, moduleId: string) { + return page.locator(`[data-module="${moduleId}"]`).evaluate((module) => { + const visible = (node: Element) => { + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + return rect.width > 0 + && rect.height > 0 + && style.display !== "none" + && style.visibility !== "hidden"; + }; + const overlapArea = (left: DOMRect, right: DOMRect) => ( + Math.max(0, Math.min(left.right, right.right) - Math.max(left.left, right.left)) + * Math.max(0, Math.min(left.bottom, right.bottom) - Math.max(left.top, right.top)) + ); + const moduleRect = module.getBoundingClientRect(); + const interactive = Array.from( + module.querySelectorAll<HTMLElement>("[data-param-id][role]"), + ).filter(visible); + const visibleSubjects = Array.from(module.querySelectorAll<HTMLElement>( + ".asset-control, .label, .module-title, .module-display, .fader, [data-param-id][role]", + )).filter(visible); + const labels = Array.from( + module.querySelectorAll<HTMLElement>(".label, .module-title"), + ).filter(visible); + const containment: string[] = []; + const hitOverlaps: string[] = []; + const labelOverlaps: string[] = []; + const textOverflow: string[] = []; + + for (const node of visibleSubjects) { + const rect = node.getBoundingClientRect(); + if (rect.left < moduleRect.left - 1 + || rect.top < moduleRect.top - 1 + || rect.right > moduleRect.right + 1 + || rect.bottom > moduleRect.bottom + 1) { + containment.push( + node.dataset.paramId + ?? node.textContent?.replace(/\s+/g, " ").trim() + ?? node.className, + ); + } + } + for (let leftIndex = 0; leftIndex < interactive.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < interactive.length; rightIndex += 1) { + const area = overlapArea( + interactive[leftIndex].getBoundingClientRect(), + interactive[rightIndex].getBoundingClientRect(), + ); + if (area > 0.5) { + hitOverlaps.push( + `${interactive[leftIndex].dataset.paramId}:${interactive[rightIndex].dataset.paramId}:${area.toFixed(2)}`, + ); + } + } + } + for (let leftIndex = 0; leftIndex < labels.length; leftIndex += 1) { + if (labels[leftIndex].scrollWidth > labels[leftIndex].clientWidth + 1 + || labels[leftIndex].scrollHeight > labels[leftIndex].clientHeight + 1) { + textOverflow.push(labels[leftIndex].textContent?.replace(/\s+/g, " ").trim() ?? "label"); + } + for (let rightIndex = leftIndex + 1; rightIndex < labels.length; rightIndex += 1) { + const area = overlapArea( + labels[leftIndex].getBoundingClientRect(), + labels[rightIndex].getBoundingClientRect(), + ); + if (area > 0.5) { + labelOverlaps.push( + `${labels[leftIndex].textContent?.trim()}:${labels[rightIndex].textContent?.trim()}:${area.toFixed(2)}`, + ); + } + } + } + return { containment, hitOverlaps, labelOverlaps, textOverflow }; + }); +} + +async function manifestProjectionFailures( + page: Page, + moduleId: string, + manifest: FaceplateManifest, + ignoredControlIds: readonly string[] = [], +) { + const ignored = new Set(ignoredControlIds); + const controls = manifest.controls + .filter(({ id }) => !ignored.has(id)) + .map((control) => ({ + id: control.id, + paramId: control.paramId, + kind: control.kind, + hit: faceplateControlHitRect(control), + visual: faceplateControlVisualRect(control), + })); + return page.locator(`[data-module="${moduleId}"]`).evaluate( + (module, contract) => { + const frameNode = module.querySelector<HTMLElement>(".module-frame"); + const frame = frameNode?.getBoundingClientRect(); + if (!frame || !frameNode) return ["missing-module-frame"]; + const scaleX = frame.width / contract.assetSize.width; + const scaleY = frame.height / contract.assetSize.height; + const cssToViewportX = frame.width / Math.max(frameNode.offsetWidth, 1); + const cssToViewportY = frame.height / Math.max(frameNode.offsetHeight, 1); + const alpha = { + left: frame.left + contract.visibleAlpha.x * scaleX, + top: frame.top + contract.visibleAlpha.y * scaleY, + right: frame.left + (contract.visibleAlpha.x + contract.visibleAlpha.width) * scaleX, + bottom: frame.top + (contract.visibleAlpha.y + contract.visibleAlpha.height) * scaleY, + }; + const failures: string[] = []; + for (const expected of contract.controls) { + const candidates = Array.from( + module.querySelectorAll<HTMLElement>(`[data-param-id="${expected.paramId}"][role]`), + ); + const node = candidates.find((candidate) => { + if (expected.kind === "fader") return candidate.classList.contains("fader"); + return !candidate.classList.contains("fader"); + }); + if (!node) { + failures.push(`${expected.id}:missing-hit`); + continue; + } + const actual = node.getBoundingClientRect(); + const target = { + left: frame.left + expected.hit.x * scaleX, + top: frame.top + expected.hit.y * scaleY, + width: expected.hit.width * scaleX, + height: expected.hit.height * scaleY, + }; + const centreDelta = Math.hypot( + actual.left + actual.width / 2 - (target.left + target.width / 2), + actual.top + actual.height / 2 - (target.top + target.height / 2), + ); + if (centreDelta > 1.1 + || Math.abs(actual.width - target.width) > 1.1 + || Math.abs(actual.height - target.height) > 1.1) { + failures.push(`${expected.id}:hit-geometry`); + } + if (actual.left < alpha.left - 1 + || actual.top < alpha.top - 1 + || actual.right > alpha.right + 1 + || actual.bottom > alpha.bottom + 1) { + failures.push(`${expected.id}:hit-outside-painted-enclosure`); + } + + if (expected.kind !== "fader") { + let artwork = node.nextElementSibling as HTMLElement | null; + while (artwork && !artwork.classList.contains("asset-control")) { + artwork = artwork.nextElementSibling as HTMLElement | null; + } + if (!artwork) { + failures.push(`${expected.id}:missing-artwork`); + continue; + } + const artRect = artwork.getBoundingClientRect(); + const artStyle = getComputedStyle(artwork); + const untransformedWidth = Number.parseFloat(artStyle.width) * cssToViewportX; + const untransformedHeight = Number.parseFloat(artStyle.height) * cssToViewportY; + const visualTarget = { + left: frame.left + expected.visual.x * scaleX, + top: frame.top + expected.visual.y * scaleY, + width: expected.visual.width * scaleX, + height: expected.visual.height * scaleY, + }; + const visualCentreDelta = Math.hypot( + artRect.left + artRect.width / 2 - (visualTarget.left + visualTarget.width / 2), + artRect.top + artRect.height / 2 - (visualTarget.top + visualTarget.height / 2), + ); + if (visualCentreDelta > 1.1 + || Math.abs(untransformedWidth - visualTarget.width) > 1.1 + || Math.abs(untransformedHeight - visualTarget.height) > 1.1) { + failures.push( + `${expected.id}:artwork-geometry:` + + `${visualCentreDelta.toFixed(2)}:` + + `${untransformedWidth.toFixed(2)}x${untransformedHeight.toFixed(2)}:` + + `${visualTarget.width.toFixed(2)}x${visualTarget.height.toFixed(2)}`, + ); + } + if (artRect.left < alpha.left - 1 + || artRect.top < alpha.top - 1 + || artRect.right > alpha.right + 1 + || artRect.bottom > alpha.bottom + 1) { + failures.push(`${expected.id}:artwork-outside-painted-enclosure`); + } + } + } + return failures; + }, + { + assetSize: manifest.assetSize, + visibleAlpha: manifest.visibleAlphaBounds, + controls, + }, + ); +} + +test("approved Amp uses one control deck and retains all ten wrapper parameters", async ({ page }) => { + await page.setViewportSize({ width: 1366, height: 768 }); + const host = await openRackSection(page, "amp"); + const module = host.locator('[data-module="amp-head"]'); + + expect(await interactiveParamIds(page, "amp-head")) + .toEqual([...AMP_PARAM_IDS].sort()); + expect(await surfaceGeometryFailures(page, "amp-head")).toEqual({ + containment: [], + hitOverlaps: [], + labelOverlaps: [], + textOverflow: [], + }); + + const row = await module.locator("[data-param-id][role]").evaluateAll((nodes) => { + const centers = nodes.map((node) => { + const rect = node.getBoundingClientRect(); + return rect.top + rect.height / 2; + }); + return { + count: centers.length, + spread: Math.max(...centers) - Math.min(...centers), + }; + }); + expect(row).toEqual({ count: 10, spread: expect.any(Number) }); + expect(row.spread).toBeLessThanOrEqual(2); + await expect(module).not.toContainText(/\b(?:POST|MASTER)\b/i); + await expect(module.locator(".amp-gain-label-overlay")).toHaveCount(0); + await expect(module.locator('.module-skin[data-rack-design-asset-id="amp-head-body-v5"]')) + .toHaveCount(1); + const ampStatusLeds = module.locator( + '.asset-control.led[data-param-id][data-nam-exact-size-variant="panel-led"]', + ); + await expect(ampStatusLeds).toHaveCount(3); + for (const paramId of ["ampEnabled", "ampBoost", "ampVoice"] as const) { + const toggle = module.locator(`[data-param-id="${paramId}"][role="switch"]`); + const toggleArtwork = module.locator( + `.asset-control.toggle[data-param-id="${paramId}"]`, + ); + const led = module.locator(`.asset-control.led[data-param-id="${paramId}"]`); + await expect(toggle).toHaveCount(1); + await expect(toggleArtwork).toHaveCount(1); + await expect(led).toHaveCount(1); + const placement = await Promise.all([ + toggleArtwork.boundingBox(), + led.boundingBox(), + ]); + expect(placement[0]).not.toBeNull(); + expect(placement[1]).not.toBeNull(); + expect( + Math.abs( + placement[0]!.x + placement[0]!.width / 2 + - (placement[1]!.x + placement[1]!.width / 2), + ), + ).toBeLessThanOrEqual(1); + expect(placement[1]!.y + placement[1]!.height).toBeLessThan(placement[0]!.y); + } + await expect(module.locator('[data-param-id="ampGainDb"][role="slider"]')) + .toHaveAttribute("aria-label", /Capture Gain/i); + + const tight = module.locator('[data-param-id="ampBoost"][role="switch"]'); + await expect(tight).toHaveAttribute("aria-checked", "false"); + await expect(module.locator('.asset-control.led[data-param-id="ampBoost"]')) + .toHaveAttribute("data-rack-design-asset-id", "led-amber-off-panel-v4"); + await tight.focus(); + await page.keyboard.press("Enter"); + await expect(tight).toHaveAttribute("aria-checked", "true"); + await expect(module.locator('.asset-control.led[data-param-id="ampBoost"]')) + .toHaveAttribute("data-rack-design-asset-id", "led-amber-on-panel-v4"); + + const bass = module.locator('[data-param-id="bassDb"][role="slider"]'); + await bass.focus(); + await page.keyboard.press("End"); + await expect(bass).toHaveAttribute("aria-valuenow", "12"); + await expect.poll(async () => (await readRackValues(page)).ampBoost).toBe(1); + await expect.poll(async () => (await readRackValues(page)).bassDb).toBe(12); +}); + +test("approved post-cab EQ has nine faders and a three-rotary utility tier", async ({ page }) => { + await page.setViewportSize({ width: 1366, height: 768 }); + const host = await openRackSection(page, "eq"); + const module = host.locator('[data-module="eq-rack"]'); + + const background = await host.locator('.premium-stage-canvas[data-design-section="eq"]').evaluate( + (canvas) => ({ + ownImage: getComputedStyle(canvas).backgroundImage, + beforeImage: getComputedStyle(canvas, "::before").backgroundImage, + afterDisplay: getComputedStyle(canvas, "::after").display, + }), + ); + expect(background.ownImage).toContain("rack-studio-backdrop-v2"); + expect(background.beforeImage).not.toBe("none"); + expect(background.afterDisplay).not.toBe("none"); + const moduleSkin = module.locator(".module-skin"); + await expect(moduleSkin).toHaveCount(1); + await expect(moduleSkin).toHaveAttribute("data-rack-design-asset-id", "graphic-eq-body-v6"); + + expect(await interactiveParamIds(page, "eq-rack")) + .toEqual([...POST_EQ_PARAM_IDS].sort()); + expect(await surfaceGeometryFailures(page, "eq-rack")).toEqual({ + containment: [], + hitOverlaps: [], + labelOverlaps: [], + textOverflow: [], + }); + await expect(module.locator(".fader[data-param-id]")).toHaveCount(9); + for (const paramId of POST_EQ_BAND_IDS) { + await expect(module.locator(`.fader[data-param-id="${paramId}"]`)).toHaveCount(1); + } + await expect(module.locator('.fader[data-param-id="eqLevelDb"]')).toHaveCount(0); + for (const paramId of ["eqHPFHz", "eqLevelDb", "eqLPFHz"] as const) { + await expect(module.locator(`[data-param-id="${paramId}"][role="slider"]`)).toHaveCount(1); + } + await expect(module.locator(".eq-filter-readout")).toHaveCount(0); + await expect(module).not.toContainText("ACTIVE"); + + const level = module.locator('[data-param-id="eqLevelDb"][role="slider"]'); + await level.focus(); + await page.keyboard.press("End"); + await expect(level).toHaveAttribute("aria-valuenow", "12"); + const hpf = module.locator('[data-param-id="eqHPFHz"][role="slider"]'); + await hpf.focus(); + await page.keyboard.press("Home"); + await expect(hpf).toHaveAttribute("aria-valuetext", "OFF"); + await expect.poll(async () => (await readRackValues(page)).eqLevelDb).toBe(12); + await expect.poll(async () => (await readRackValues(page)).eqHPFHz).toBe(0); +}); + +test("Amp, EQ, EQ Boost, and Drive hardware remain inside their painted borders at every supported host size", async ({ page }) => { + const viewports = [ + { width: 920, height: 760 }, + { width: 1024, height: 700 }, + { width: 1366, height: 768 }, + { width: 1920, height: 1080 }, + { width: 3840, height: 2160 }, + ]; + + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await openRackSection(page, "amp"); + expect( + await manifestProjectionFailures( + page, + "amp-head", + NAM_AMP_V4_FACEPLATE, + ["amp-power-led", "amp-tight-led", "amp-bright-led"], + ), + `Amp geometry at ${viewport.width}x${viewport.height}`, + ).toEqual([]); + + await openRackSection(page, "eq"); + expect( + await manifestProjectionFailures( + page, + "eq-rack", + NAM_EQ_V4_FACEPLATE, + // Power and its passive status LED intentionally share eqEnabled, so + // this generic param-id matcher cannot distinguish their hit nodes. + ["eq-led"], + ), + `EQ geometry at ${viewport.width}x${viewport.height}`, + ).toEqual([]); + + await openRackSection(page, "pre"); + expect( + await surfaceGeometryFailures(page, "eq-boost"), + `EQ Boost geometry at ${viewport.width}x${viewport.height}`, + ).toEqual({ + containment: [], + hitOverlaps: [], + labelOverlaps: [], + textOverflow: [], + }); + expect( + await surfaceGeometryFailures(page, "precision-drive"), + `Precision Drive geometry at ${viewport.width}x${viewport.height}`, + ).toEqual({ + containment: [], + hitOverlaps: [], + labelOverlaps: [], + textOverflow: [], + }); + } +}); + +test("compact-height hosts keep the rack frame symmetric without losing vertical scrolling", async ({ page }) => { + for (const height of [688, 699, 700]) { + await page.setViewportSize({ width: 360, height }); + const host = await openRackSection(page, "pre"); + const metrics = await host.evaluate((root) => { + const shell = root.querySelector<HTMLElement>(".premium-nam-shell"); + if (!shell) throw new Error("Missing premium NAM shell"); + const rootRect = root.getBoundingClientRect(); + const shellRect = shell.getBoundingClientRect(); + const style = getComputedStyle(root); + const maxScrollTop = Math.max(0, root.scrollHeight - root.clientHeight); + root.scrollTop = Math.min(12, maxScrollTop); + return { + clientWidth: root.clientWidth, + renderedWidth: rootRect.width, + scrollWidth: root.scrollWidth, + scrollHeight: root.scrollHeight, + clientHeight: root.clientHeight, + scrollTop: root.scrollTop, + leftInset: shellRect.left - rootRect.left, + rightInset: rootRect.right - shellRect.right, + overflowY: style.overflowY, + scrollbarGutter: style.scrollbarGutter, + scrollbarWidth: style.scrollbarWidth, + documentOverflow: + document.documentElement.scrollWidth + - document.documentElement.clientWidth, + }; + }); + + expect(metrics.clientWidth).toBeCloseTo(metrics.renderedWidth, 1); + expect(metrics.scrollWidth).toBe(metrics.clientWidth); + expect(metrics.leftInset).toBeCloseTo(metrics.rightInset, 1); + expect(metrics.documentOverflow).toBe(0); + + if (height < 700) { + expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight); + expect(metrics.scrollTop).toBeGreaterThan(0); + expect(metrics.overflowY).toBe("auto"); + expect(metrics.scrollbarGutter).toBe("auto"); + expect(metrics.scrollbarWidth).toBe("none"); + } else { + expect(metrics.scrollHeight).toBe(metrics.clientHeight); + expect(metrics.overflowY).toBe("hidden"); + } + } +}); + +test("EQ Boost and Drive expose separate controls without collisions", async ({ page }) => { + await page.setViewportSize({ width: 1366, height: 768 }); + await openRackSection(page, "pre"); + const eqBoost = page.locator('[data-module="eq-boost"]'); + const drive = page.locator('[data-module="precision-drive"]'); + + expect(await interactiveParamIds(page, "eq-boost")).toEqual([...PRE_EQ_PARAM_IDS].sort()); + expect(await interactiveParamIds(page, "precision-drive")).toEqual([...DRIVE_PARAM_IDS].sort()); + expect(await interactiveParamIds(page, "eq-boost")).toHaveLength(11); + expect(await interactiveParamIds(page, "precision-drive")).toHaveLength(6); + await expect(eqBoost.locator('[data-param-id="preEqLevelDb"]')).toHaveCount(0); + await expect(drive.locator('[data-param-id^="preEq"]')).toHaveCount(0); + await expect(eqBoost.locator('[data-param-id^="precisionDrive"]')).toHaveCount(0); + expect(await surfaceGeometryFailures(page, "eq-boost")).toEqual({ + containment: [], + hitOverlaps: [], + labelOverlaps: [], + textOverflow: [], + }); + expect(await surfaceGeometryFailures(page, "precision-drive")).toEqual({ + containment: [], + hitOverlaps: [], + labelOverlaps: [], + textOverflow: [], + }); + + await expect(eqBoost.locator(".combined-pre-eq-band-label")).toHaveCount(8); + await expect(eqBoost).toContainText("120"); + await expect(eqBoost).toContainText("2.5K"); + await expect(eqBoost).toContainText("12K"); + const bandAlignment = await eqBoost.locator(".combined-pre-eq-band").evaluateAll((rows) => ( + rows.map((row) => { + const label = row.querySelector<HTMLElement>(".combined-pre-eq-band-label"); + const track = row.querySelector<HTMLElement>(".horizontal-mini-fader-track"); + if (!label || !track) throw new Error("Incomplete EQ Boost band row"); + const labelRect = label.getBoundingClientRect(); + const trackRect = track.getBoundingClientRect(); + return { + gap: trackRect.left - labelRect.right, + verticalDelta: Math.abs( + labelRect.top + labelRect.height / 2 + - (trackRect.top + trackRect.height / 2), + ), + }; + }) + )); + expect(bandAlignment).toHaveLength(8); + for (const row of bandAlignment) { + expect(row.gap).toBeGreaterThanOrEqual(3); + expect(row.gap).toBeLessThanOrEqual(5); + expect(row.verticalDelta).toBeLessThanOrEqual(0.5); + } + const titleAlignment = await page.evaluate(() => { + const read = (moduleId: string) => { + const module = document.querySelector<HTMLElement>(`[data-module="${moduleId}"]`); + const title = module?.querySelector<HTMLElement>(".module-title"); + if (!module || !title) throw new Error(`Missing title for ${moduleId}`); + const moduleRect = module.getBoundingClientRect(); + const titleRect = title.getBoundingClientRect(); + return (titleRect.top + titleRect.height / 2 - moduleRect.top) / moduleRect.height; + }; + return { + compressor: read("compressor"), + eqBoost: read("eq-boost"), + distortion: read("distortion"), + }; + }); + expect(titleAlignment.eqBoost).toBeCloseTo(titleAlignment.compressor, 2); + expect(titleAlignment.eqBoost).toBeCloseTo(titleAlignment.distortion, 2); + + for (const paramId of PRE_EQ_PARAM_IDS.slice(1, 9)) { + await expect(eqBoost.locator(`[data-param-id="${paramId}"][role="slider"]`)).toHaveCount(1); + } + const firstBand = eqBoost.locator('[data-param-id="preEq120Db"][role="slider"]'); + await firstBand.focus(); + await page.keyboard.press("End"); + await expect(firstBand).toHaveAttribute("aria-valuenow", "12"); + await firstBand.hover(); + await expect(page.locator(".nam-rack-control-tooltip")).toBeVisible(); + await expect(page.locator(".nam-rack-control-tooltip")).toContainText("dB"); + + const preEqPower = eqBoost.locator('[data-param-id="preEqEnabled"][role="button"]'); + const drivePower = drive.locator('[data-param-id="precisionDriveEnabled"][role="button"]'); + await expect(preEqPower).toHaveAttribute("aria-pressed", "false"); + await expect(drivePower).toHaveAttribute("aria-pressed", "false"); + await preEqPower.focus(); + await page.keyboard.press("Enter"); + await expect(preEqPower).toHaveAttribute("aria-pressed", "true"); + await expect(drivePower).toHaveAttribute("aria-pressed", "false"); + await drivePower.focus(); + await page.keyboard.press("Enter"); + await expect(preEqPower).toHaveAttribute("aria-pressed", "true"); + await expect(drivePower).toHaveAttribute("aria-pressed", "true"); + + await expect.poll(async () => (await readRackValues(page)).preEq120Db).toBe(12); + await expect.poll(async () => (await readRackValues(page)).preEqEnabled).toBe(1); + await expect.poll(async () => (await readRackValues(page)).precisionDriveEnabled).toBe(1); + + const footStateStyles = await page.locator(".primary-foot-state").evaluateAll((nodes) => ( + nodes.map((node) => { + const style = getComputedStyle(node); + return { + backgroundColor: style.backgroundColor, + borderStyle: style.borderStyle, + borderRadius: style.borderRadius, + boxShadow: style.boxShadow, + paddingLeft: style.paddingLeft, + paddingRight: style.paddingRight, + }; + }) + )); + for (const style of footStateStyles) { + expect(style).toEqual({ + backgroundColor: "rgba(0, 0, 0, 0)", + borderStyle: "none", + borderRadius: "0px", + boxShadow: "none", + paddingLeft: "0px", + paddingRight: "0px", + }); + } +}); + +test("PRE row keeps five sibling pedals visible without horizontal scrolling", async ({ page }) => { + await page.setViewportSize({ width: 920, height: 760 }); + const host = await openRackSection(page, "pre"); + const expectedBoxes = { + compressor: { x: 85, y: 42, w: 156, h: 232 }, + octaver: { x: 251, y: 42, w: 120, h: 232 }, + "eq-boost": { x: 381, y: 42, w: 156, h: 232 }, + "precision-drive": { x: 547, y: 42, w: 120, h: 232 }, + distortion: { x: 677, y: 42, w: 156, h: 232 }, + } as const; + + const metrics = await host.evaluate((root, expected) => { + const artboard = root.querySelector<HTMLElement>(".nam-rack-artboard"); + if (!artboard) throw new Error("Missing PRE artboard"); + const modules = Object.fromEntries(Object.entries(expected).map(([id, box]) => { + const node = root.querySelector<HTMLElement>(`[data-module="${id}"]`); + if (!node) throw new Error(`Missing PRE module ${id}`); + const rect = node.getBoundingClientRect(); + return [id, { + logical: { + x: Number.parseFloat(node.style.left), + y: Number.parseFloat(node.style.top), + w: Number.parseFloat(node.style.width), + h: Number.parseFloat(node.style.height), + }, + rendered: { + left: rect.left, + right: rect.right, + width: rect.width, + height: rect.height, + }, + scaleX: rect.width / box.w, + scaleY: rect.height / box.h, + snapAlign: getComputedStyle(node).scrollSnapAlign, + }]; + })); + const scroller = root.querySelector<HTMLElement>('[data-qa="nam-pre-stage-scroll"]'); + if (!scroller) throw new Error("Missing local PRE row viewport"); + const scrollerRect = scroller.getBoundingClientRect(); + return { + artboardWidth: Number.parseFloat(getComputedStyle(artboard).width), + artboardHeight: Number.parseFloat(getComputedStyle(artboard).height), + modules, + scroller: { + clientWidth: scroller.clientWidth, + scrollWidth: scroller.scrollWidth, + scrollLeft: scroller.scrollLeft, + snapType: getComputedStyle(scroller).scrollSnapType, + overflowX: getComputedStyle(scroller).overflowX, + required: scroller.dataset.scrollRequired, + left: scrollerRect.left, + right: scrollerRect.right, + }, + rootPageOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + }; + }, expectedBoxes); + + expect(metrics.artboardWidth).toBeCloseTo(918, 1); + expect(metrics.artboardHeight).toBeCloseTo(341, 1); + expect(metrics.rootPageOverflow).toBe(false); + expect(metrics.scroller.scrollWidth - metrics.scroller.clientWidth).toBeLessThanOrEqual(2); + expect(metrics.scroller.scrollLeft).toBe(0); + expect(metrics.scroller.snapType).toBe("none"); + expect(metrics.scroller.overflowX).toBe("hidden"); + expect(metrics.scroller.required).toBe("false"); + for (const [id, expected] of Object.entries(expectedBoxes)) { + const got = metrics.modules[id as keyof typeof metrics.modules]; + expect(got.logical).toEqual(expected); + expect(got.scaleX).toBeCloseTo(got.scaleY, 2); + expect(got.snapAlign).not.toBe(""); + } + const scaleValues = Object.values(metrics.modules).map((module) => module.scaleX); + expect(Math.max(...scaleValues) - Math.min(...scaleValues)).toBeLessThan(0.01); + const ordered = Object.values(metrics.modules); + for (let index = 1; index < ordered.length; index += 1) { + const logicalGap = (ordered[index].rendered.left - ordered[index - 1].rendered.right) + / ordered[index].scaleX; + expect(logicalGap).toBeCloseTo(10, 1); + } + await expect(host.locator(".nam-pre-stage-snap-anchor")).toHaveCount(5); + const compressorFootPadding = await host.locator('[data-module="compressor"]').evaluate((module) => { + const foot = module.querySelector<HTMLElement>( + '.asset-control.footswitch[data-param-id="compressorEnabled"]', + ); + if (!foot) throw new Error("Missing compressor footswitch artwork"); + const moduleRect = module.getBoundingClientRect(); + const footRect = foot.getBoundingClientRect(); + return { + gap: moduleRect.right - footRect.right, + designScale: moduleRect.width / 156, + }; + }); + expect(compressorFootPadding.gap / compressorFootPadding.designScale) + .toBeGreaterThanOrEqual(20); + await host.locator('[data-param-id="chaosTone"][role="slider"]').focus(); + await expect.poll(() => host.locator('[data-qa="nam-pre-stage-scroll"]').evaluate( + (scroller) => scroller.scrollLeft, + )).toBe(0); +}); diff --git a/frontend/e2e/profile-onboarding.spec.ts b/frontend/e2e/profile-onboarding.spec.ts new file mode 100644 index 0000000..3f143d2 --- /dev/null +++ b/frontend/e2e/profile-onboarding.spec.ts @@ -0,0 +1,314 @@ +import { expect, test } from "@playwright/test"; + +const PROFILE_SETTINGS_KEY = "openstudio.inputProfiles.v1"; +const CUSTOM_KEYBOARD_PROFILES_KEY = "openstudio.keyboardProfiles.v2"; +const MOUSE_MODIFIER_OVERRIDES_KEY = "openstudio.mouseModifierOverrides.v1"; +const IS_MAC_HOST = process.platform === "darwin"; +const PRIMARY_KEY = IS_MAC_HOST ? "Meta" : "Control"; +const PRIMARY_LABEL = IS_MAC_HOST ? "Cmd" : "Ctrl"; +const HOST_OVERRIDE_TARGET = IS_MAC_HOST ? "macos" : "windows"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.evaluate(({ settingsKey, customProfilesKey, mouseOverridesKey }) => { + localStorage.removeItem(settingsKey); + localStorage.removeItem(customProfilesKey); + localStorage.removeItem(mouseOverridesKey); + localStorage.removeItem("openstudio_essentialControlsDismissed"); + localStorage.removeItem("s13_customShortcuts"); + }, { + settingsKey: PROFILE_SETTINGS_KEY, + customProfilesKey: CUSTOM_KEYBOARD_PROFILES_KEY, + mouseOverridesKey: MOUSE_MODIFIER_OVERRIDES_KEY, + }); + await page.reload(); + await expect(page.getByRole("heading", { name: "Make OpenStudio feel familiar" })).toBeVisible(); +}); + +test("first-run chooser remains usable in a compact viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 620 }); + const chooser = page.getByRole("region", { name: "Choose input profiles" }); + await expect(chooser).toBeVisible(); + const bounds = await chooser.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.y).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(620); + + await page.getByLabel("Keyboard profile").selectOption("reaper"); + await page.getByLabel("Mouse & scroll profile").selectOption("logic_pro"); + await expect(page.getByLabel("Keyboard profile")).toHaveValue("reaper"); + await expect(page.getByLabel("Mouse & scroll profile")).toHaveValue("logic_pro"); +}); + +test("chooser exposes the additional platform-qualified DAW profiles", async ({ page }) => { + const keyboard = page.getByLabel("Keyboard profile"); + const mouse = page.getByLabel("Mouse & scroll profile"); + for (const [value, label] of [ + ["cakewalk_sonar", IS_MAC_HOST ? "Cakewalk / Sonar (cross-platform emulation)" : "Cakewalk / Sonar"], + ["garageband", IS_MAC_HOST ? "GarageBand" : "GarageBand (cross-platform emulation)"], + ["digital_performer", "Digital Performer"], + ["adobe_audition", "Adobe Audition"], + ["mixcraft", IS_MAC_HOST ? "Mixcraft (cross-platform emulation)" : "Mixcraft"], + ["waveform", "Waveform"], + ["renoise", "Renoise"], + ] as const) { + await expect(keyboard.locator(`option[value="${value}"]`)).toHaveText(label); + await expect(mouse.locator(`option[value="${value}"]`)).toHaveText(label); + } + + await keyboard.selectOption("cakewalk_sonar"); + await mouse.selectOption("digital_performer"); + await expect(keyboard).toHaveValue("cakewalk_sonar"); + await expect(mouse).toHaveValue("digital_performer"); +}); + +test("first-run profile choices persist independently across reload", async ({ page }) => { + await page.getByLabel("Keyboard profile").selectOption("reaper"); + await page.getByLabel("Mouse & scroll profile").selectOption("logic_pro"); + await page.getByRole("button", { name: "Use these profiles" }).click(); + + await expect(page.getByRole("region", { name: "Choose input profiles" })).toBeHidden(); + const persisted = await page.evaluate((settingsKey) => ( + JSON.parse(localStorage.getItem(settingsKey) ?? "{}") + ), PROFILE_SETTINGS_KEY); + expect(persisted).toMatchObject({ + schemaVersion: 1, + keyboardProfileId: "reaper", + mouseProfileId: "logic_pro", + onboardingSeen: true, + }); + + await page.reload(); + await expect(page.getByRole("heading", { name: "Make OpenStudio feel familiar" })).toHaveCount(0); +}); + +test("Review shortcuts carries selections into the full editor", async ({ page }) => { + await page.getByLabel("Keyboard profile").selectOption("pro_tools"); + await page.getByLabel("Mouse & scroll profile").selectOption("cubase"); + await page.getByRole("button", { name: "Review shortcuts" }).click(); + + const dialog = page.getByRole("dialog", { name: "Keyboard Shortcuts" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("Keyboard profile")).toHaveValue("pro_tools"); + await expect(dialog.getByLabel("Mouse & scroll profile")).toHaveValue("cubase"); + await expect(dialog.getByText(/Scoped bindings apply only in their named editors/)).toBeVisible(); + const recordRow = dialog.getByTitle("Record").locator(".."); + await expect(recordRow.getByText(`${PRIMARY_LABEL}+Space`, { exact: true })).toBeVisible(); + await expect(recordRow.getByText("F12", { exact: true })).toBeVisible(); +}); + +test("shortcut rows expose actions, unassigned state, rebinding status, and all scopes", async ({ page }) => { + await page.getByLabel("Keyboard profile").selectOption("pro_tools"); + await page.getByRole("button", { name: "Review shortcuts" }).click(); + + const dialog = page.getByRole("dialog", { name: "Keyboard Shortcuts" }); + const search = dialog.getByLabel("Search keyboard shortcuts"); + await search.fill("Split Tool"); + const splitAction = dialog.getByRole("button", { name: "Split Tool", exact: true }); + await expect(splitAction).toBeVisible(); + const splitRow = splitAction.locator(".."); + await expect(splitRow.getByText("Unassigned", { exact: true })).toBeVisible(); + + const rebind = splitRow.getByRole("button", { name: "Rebind Split Tool" }); + await rebind.focus(); + await expect(rebind).toBeFocused(); + await rebind.click(); + const captureStatus = dialog + .getByRole("status") + .filter({ hasText: "Press a key combination" }); + await expect(captureStatus).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(captureStatus).toHaveCount(0); + + await search.fill("Mute / Unmute Selected Tracks"); + const multiScopeRow = dialog + .getByRole("button", { name: "Mute / Unmute Selected Tracks", exact: true }) + .locator(".."); + await expect(multiScopeRow.getByText("Track Control Panel, Mixer", { exact: true })).toBeVisible(); +}); + +test("profile-specific selected-track commands advertise their Timeline scope", async ({ page }) => { + await page.getByLabel("Keyboard profile").selectOption("garageband"); + await page.getByRole("button", { name: "Review shortcuts" }).click(); + + const dialog = page.getByRole("dialog", { name: "Keyboard Shortcuts" }); + await dialog.getByLabel("Search keyboard shortcuts").fill("Mute / Unmute Selected Tracks"); + const row = dialog + .getByRole("button", { name: "Mute / Unmute Selected Tracks", exact: true }) + .locator(".."); + await expect(row.getByText("Track Control Panel, Mixer, Timeline", { exact: true })).toBeVisible(); + await expect(row.getByText("M", { exact: true })).toBeVisible(); +}); + +test("selected mouse profile updates essential controls and help without reload", async ({ page }) => { + await page.getByLabel("Mouse & scroll profile").selectOption("reaper"); + await page.getByRole("button", { name: "Use these profiles" }).click(); + + const essentials = page.getByRole("complementary", { name: "Navigate the timeline quickly" }); + await expect(essentials).toContainText("REAPER mouse profile"); + await expect(essentials).toContainText("Scroll: zoom the timeline"); + await essentials.getByRole("button", { name: "Open Help" }).click(); + + const help = page.getByRole("dialog", { name: "Help Reference" }); + await expect(help).toContainText("REAPER: Scroll zoom the timeline"); + await expect(help).toContainText("REAPER mouse"); + await expect(help.getByLabel("Search help topics")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(help).toBeHidden(); +}); + +test("Preferences persists mouse profile and per-gesture override changes", async ({ page }) => { + await page.getByRole("button", { name: "Use these profiles" }).click(); + await page.getByRole("menuitem", { name: "Options menu" }).click(); + await page.getByRole("menuitem", { name: /Preferences/ }).click(); + + let dialog = page.getByRole("dialog", { name: "Preferences" }); + const generalTab = dialog.getByRole("tab", { name: "General" }); + await generalTab.focus(); + await generalTab.press("ArrowRight"); + await expect(dialog.getByRole("tab", { name: "Editing" })).toHaveAttribute("aria-selected", "true"); + + await dialog.getByRole("tab", { name: "Mouse" }).click(); + await expect(dialog.getByRole("table", { name: /Mouse modifier actions/ })).toBeVisible(); + await expect(dialog.getByLabel("Clip Drag, Click action")).toBeVisible(); + await dialog.getByLabel("Mouse & scroll profile").selectOption("logic_pro"); + await dialog.getByLabel("Clip Drag, Click action").selectOption("copy"); + await dialog.getByRole("button", { name: "Close", exact: true }).click(); + + await page.reload(); + await page.getByRole("menuitem", { name: "Options menu" }).click(); + await page.getByRole("menuitem", { name: /Preferences/ }).click(); + dialog = page.getByRole("dialog", { name: "Preferences" }); + await dialog.getByRole("tab", { name: "Mouse" }).click(); + await expect(dialog.getByLabel("Mouse & scroll profile")).toHaveValue("logic_pro"); + await expect(dialog.getByLabel("Clip Drag, Click action")).toHaveValue("copy"); + const persistedOverrides = await page.evaluate((storageKey) => ( + JSON.parse(localStorage.getItem(storageKey) ?? "{}") + ), MOUSE_MODIFIER_OVERRIDES_KEY); + expect(persistedOverrides).toMatchObject({ + schemaVersion: 1, + overrides: { clip_drag: { none: "copy" } }, + }); +}); + +test("selected profile updates shortcut hints outside the shortcut editor", async ({ page }) => { + await page.getByLabel("Keyboard profile").selectOption("pro_tools"); + await page.getByRole("button", { name: "Use these profiles" }).click(); + + await expect(page.getByTitle(`Toggle Mixer (${PRIMARY_LABEL}+=)`)).toBeVisible(); + await expect(page.getByTitle("Select Tool (Timeline: F7)")).toBeVisible(); +}); + +test("profile commands respect active timeline context and disabled native collisions", async ({ page }) => { + await page.getByLabel("Keyboard profile").selectOption("pro_tools"); + await page.getByRole("button", { name: "Use these profiles" }).click(); + + const splitTool = page.getByRole("button", { name: "Split Tool" }); + const selectTool = page.getByRole("button", { name: "Select Tool" }); + await splitTool.click(); + await expect(splitTool).toHaveAttribute("aria-pressed", "true"); + + await page.locator(".timeline-container").click({ position: { x: 300, y: 120 } }); + await page.keyboard.press("F6"); + await expect(splitTool).toHaveAttribute("aria-pressed", "true"); + await page.keyboard.press("F7"); + await expect(selectTool).toHaveAttribute("aria-pressed", "true"); +}); + +test("real timeline wheel uses the selected REAPER modifier map", async ({ page }) => { + await page.getByLabel("Mouse & scroll profile").selectOption("reaper"); + await page.getByRole("button", { name: "Use these profiles" }).click(); + await page.getByRole("button", { name: "Add new audio track" }).click(); + + const trackHeader = page.locator('[data-track-id] [data-shortcut-context="track_control_panel"]').first(); + await expect(trackHeader).toBeVisible(); + const initialHeight = (await trackHeader.boundingBox())?.height ?? 0; + expect(initialHeight).toBeGreaterThan(0); + + await page.locator(".timeline-container").dispatchEvent("wheel", { + deltaY: -100, + ctrlKey: !IS_MAC_HOST, + metaKey: IS_MAC_HOST, + bubbles: true, + cancelable: true, + }); + await expect.poll(async () => (await trackHeader.boundingBox())?.height ?? 0).toBeGreaterThan(initialHeight); +}); + +test("real mixer parameter controls own normal and fine wheel adjustment", async ({ page }) => { + await page.getByRole("button", { name: "Use these profiles" }).click(); + const pan = page.getByRole("slider", { name: /Pan for/i }).first(); + await expect(pan).toBeVisible(); + const initial = Number(await pan.getAttribute("aria-valuenow")); + + await pan.dispatchEvent("wheel", { deltaY: -100, cancelable: true, bubbles: true }); + await expect.poll(async () => Number(await pan.getAttribute("aria-valuenow"))).toBeGreaterThan(initial); + const afterNormal = Number(await pan.getAttribute("aria-valuenow")); + + await pan.dispatchEvent("wheel", { + deltaY: -100, + shiftKey: true, + cancelable: true, + bubbles: true, + }); + await expect.poll(async () => Number(await pan.getAttribute("aria-valuenow"))).toBeGreaterThan(afterNormal); + const afterFine = Number(await pan.getAttribute("aria-valuenow")); + expect(afterFine - afterNormal).toBeLessThan(afterNormal - initial); +}); + +test("named profiles keep multiple keys, platform unbinds, persistence, and export", async ({ page }) => { + await page.getByRole("button", { name: "Review shortcuts" }).click(); + const dialog = page.getByRole("dialog", { name: "Keyboard Shortcuts" }); + + const nameInput = dialog.getByLabel("Profile name", { exact: true }); + await nameInput.fill("Editing Keys"); + await dialog.getByRole("button", { name: "New", exact: true }).click(); + await expect(dialog.getByLabel("Keyboard profile").locator("option:checked")) + .toHaveText("Custom - Editing Keys"); + + await dialog.getByLabel("Search keyboard shortcuts").fill("Play / Pause"); + const playRow = dialog.getByRole("button", { name: "Play / Pause", exact: true }).locator(".."); + await playRow.getByRole("button", { name: "Rebind Play / Pause" }).click(); + await page.keyboard.press(`${PRIMARY_KEY}+Shift+F10`); + await expect(playRow.getByText(`${PRIMARY_LABEL}+Shift+F10`, { exact: true })).toBeVisible(); + + await playRow.getByRole("button", { name: "Rebind Play / Pause" }).click(); + await page.keyboard.press(`${PRIMARY_KEY}+Shift+F11`); + await expect(playRow.getByText(`${PRIMARY_LABEL}+Shift+F11`, { exact: true })).toBeVisible(); + + await dialog.getByLabel("Edit overrides for").selectOption(HOST_OVERRIDE_TARGET); + await playRow.getByRole("button", { name: "Disable" }).click(); + await expect(playRow.getByText("Disabled here", { exact: true })).toBeVisible(); + await expect(playRow.getByText("Unassigned (custom)", { exact: true })).toBeVisible(); + + const persisted = await page.evaluate((storageKey) => ( + JSON.parse(localStorage.getItem(storageKey) ?? "{}") + ), CUSTOM_KEYBOARD_PROFILES_KEY); + expect(persisted).toMatchObject({ + schemaVersion: 2, + profiles: [{ + name: "Editing Keys", + bindings: { + "transport.play": { + common: ["Ctrl+Shift+F10", "Ctrl+Shift+F11"], + [HOST_OVERRIDE_TARGET]: [], + }, + }, + }], + }); + + const downloadPromise = page.waitForEvent("download"); + await dialog.getByRole("button", { name: "Export", exact: true }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("Editing-Keys.json"); + + await dialog.getByRole("button", { name: "Close", exact: true }).click(); + await page.reload(); + await page.getByRole("menuitem", { name: "Help menu" }).click(); + await page.getByRole("menuitem", { name: "Keyboard Shortcuts" }).click(); + const reopened = page.getByRole("dialog", { name: "Keyboard Shortcuts" }); + await expect(reopened.getByLabel("Keyboard profile").locator("option:checked")) + .toHaveText("Custom - Editing Keys"); +}); diff --git a/frontend/e2e/shortcut-foundations.spec.ts b/frontend/e2e/shortcut-foundations.spec.ts new file mode 100644 index 0000000..43c432c --- /dev/null +++ b/frontend/e2e/shortcut-foundations.spec.ts @@ -0,0 +1,305 @@ +import { expect, test, type Page } from "@playwright/test"; + +const PRIMARY_KEY = process.platform === "darwin" ? "Meta" : "Control"; + +interface SyntheticKeyOptions { + key: string; + code?: string; + location?: number; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} + +interface SyntheticWheelOptions { + deltaX?: number; + deltaY?: number; + deltaMode?: number; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + clientX?: number; + clientY?: number; +} + +async function dispatchKey( + page: Page, + targetId: string, + options: SyntheticKeyOptions, +): Promise<{ dispatchReturned: boolean; defaultPrevented: boolean }> { + return page.evaluate( + ({ id, init }) => window.dispatchHarnessKey(id, init), + { id: targetId, init: options }, + ); +} + +async function dispatchWheel( + page: Page, + targetId: string, + options: SyntheticWheelOptions, +): Promise<{ + dispatch: { dispatchReturned: boolean; defaultPrevented: boolean }; + resolved: Record<string, unknown>; +}> { + return page.evaluate(({ id, init }) => { + const dispatch = window.dispatchHarnessWheel(id, init); + const raw = document.getElementById(id)?.dataset.lastWheelResult; + if (!raw) throw new Error(`No wheel result for ${id}`); + return { dispatch, resolved: JSON.parse(raw) as Record<string, unknown> }; + }, { id: targetId, init: options }); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/shortcut-e2e.html"); + await expect(page.getByRole("heading", { name: "Shortcut and wheel test harness" })).toBeVisible(); +}); + +test("text inputs retain editing shortcuts and ordinary typing", async ({ page }) => { + const input = page.getByRole("textbox", { name: "Text input" }); + await input.focus(); + await page.keyboard.press(`${PRIMARY_KEY}+a`); + + await expect.poll(() => input.evaluate((node) => ({ + start: (node as HTMLInputElement).selectionStart, + end: (node as HTMLInputElement).selectionEnd, + length: (node as HTMLInputElement).value.length, + }))).toEqual({ start: 0, end: 16, length: 16 }); + await expect(page.getByLabel("Last shortcut result")).toContainText('"handled":false'); + + await page.keyboard.type("replacement"); + await expect(input).toHaveValue("replacement"); + await expect(page.getByLabel("Context hit counts")).toHaveText( + '{"timeline":0,"piano_roll":0,"pitch_editor":0}', + ); +}); + +test("transport Space is reserved while sliders keep native arrow behavior", async ({ page }) => { + const button = page.getByRole("button", { name: "Native button" }); + await button.focus(); + await page.keyboard.press("Space"); + await expect(page.locator("#button-click-count")).toHaveText("0"); + await expect(page.getByLabel("Last shortcut result")).toContainText('"owner":"registry"'); + await expect(page.getByLabel("Last shortcut result")).toContainText('"actionId":"transport.play"'); + + const slider = page.getByRole("slider", { name: "Native range" }); + await slider.focus(); + await page.keyboard.press("ArrowRight"); + await expect(slider).toHaveValue("6"); + await expect(page.getByLabel("Context hit counts")).toHaveText( + '{"timeline":0,"piano_roll":0,"pitch_editor":0}', + ); +}); + +test("the most recently focused editor context owns the same shortcut", async ({ page }) => { + await page.getByLabel("Active shortcut binding").selectOption("X"); + + const cases = [ + { label: "Timeline surface", context: "timeline", counts: { timeline: 1, piano_roll: 0, pitch_editor: 0 } }, + { label: "Piano roll surface", context: "piano_roll:e2e-piano", counts: { timeline: 1, piano_roll: 1, pitch_editor: 0 } }, + { label: "Pitch editor surface", context: "pitch_editor", counts: { timeline: 1, piano_roll: 1, pitch_editor: 1 } }, + { label: "Timeline surface", context: "timeline", counts: { timeline: 2, piano_roll: 1, pitch_editor: 1 } }, + ] as const; + + for (const entry of cases) { + await page.getByRole("region", { name: entry.label }).focus(); + await expect(page.getByLabel("Active context")).toHaveText(entry.context); + await page.keyboard.press("x"); + await expect(page.getByLabel("Context hit counts")).toHaveText(JSON.stringify(entry.counts)); + } +}); + +test("Windows Control bindings work through the shared dispatcher", async ({ page }) => { + await page.getByLabel("Keyboard platform").selectOption("windows"); + await page.getByLabel("Active shortcut binding").selectOption("Ctrl+X"); + await page.getByRole("region", { name: "Timeline surface" }).focus(); + + await page.keyboard.press("Control+x"); + + await expect(page.getByLabel("Last shortcut result")).toContainText('"owner":"timeline"'); + await expect(page.getByLabel("Last shortcut result")).toContainText('"platform":"windows"'); + await expect(page.getByLabel("Context hit counts")).toContainText('"timeline":1'); +}); + +test("simulated macOS Command, Option, and physical Control stay distinct", async ({ page }) => { + await page.getByLabel("Keyboard platform").selectOption("macos"); + + const cases = [ + { + binding: "Command+X", + event: { key: "x", code: "KeyX", metaKey: true }, + }, + { + binding: "Option+Code:KeyX", + event: { key: "≈", code: "KeyX", altKey: true }, + }, + { + binding: "Control+X", + event: { key: "x", code: "KeyX", ctrlKey: true }, + }, + ] as const; + + for (const entry of cases) { + await page.getByLabel("Active shortcut binding").selectOption(entry.binding); + const dispatched = await dispatchKey(page, "pitch-surface", entry.event); + expect(dispatched).toEqual({ dispatchReturned: false, defaultPrevented: true }); + } + + await expect(page.getByLabel("Context hit counts")).toHaveText( + '{"timeline":0,"piano_roll":0,"pitch_editor":3}', + ); +}); + +test("the host operating system emits its native primary and secondary modifiers", async ({ page }) => { + const isMacHost = process.platform === "darwin"; + await page.getByLabel("Keyboard platform").selectOption(isMacHost ? "macos" : "windows"); + await page.getByRole("region", { name: "Timeline surface" }).focus(); + + await page.getByLabel("Active shortcut binding").selectOption( + isMacHost ? "Command+X" : "Ctrl+X", + ); + await page.keyboard.press(isMacHost ? "Meta+x" : "Control+x"); + await expect(page.getByLabel("Context hit counts")).toContainText('"timeline":1'); + + if (isMacHost) { + await page.getByLabel("Active shortcut binding").selectOption("Option+Code:KeyX"); + await page.keyboard.press("Alt+x"); + await page.getByLabel("Active shortcut binding").selectOption("Control+X"); + await page.keyboard.press("Control+x"); + await expect(page.getByLabel("Context hit counts")).toContainText('"timeline":3'); + } +}); + +test("physical key-position and numpad bindings do not collapse to labels", async ({ page }) => { + await page.getByLabel("Keyboard platform").selectOption("windows"); + await page.getByLabel("Active shortcut binding").selectOption("Control+Code:KeyZ"); + + const physical = await dispatchKey(page, "piano-surface", { + key: "y", + code: "KeyZ", + ctrlKey: true, + }); + expect(physical.defaultPrevented).toBe(true); + await expect(page.getByLabel("Context hit counts")).toContainText('"piano_roll":1'); + + await page.getByLabel("Active shortcut binding").selectOption("Numpad1"); + const topRow = await dispatchKey(page, "piano-surface", { + key: "1", + code: "Digit1", + location: 0, + }); + expect(topRow.defaultPrevented).toBe(false); + await expect(page.getByLabel("Context hit counts")).toContainText('"piano_roll":1'); + + const numpad = await dispatchKey(page, "piano-surface", { + key: "1", + code: "Numpad1", + location: 3, + }); + expect(numpad).toEqual({ dispatchReturned: false, defaultPrevented: true }); + await expect(page.getByLabel("Context hit counts")).toContainText('"piano_roll":2'); +}); + +test("timeline wheel precedence covers Windows modifier combinations", async ({ page }) => { + await page.getByLabel("Wheel platform").selectOption("windows"); + const cases = [ + { init: { deltaY: 10 }, rule: "timeline.native-scroll", prevented: false }, + { init: { deltaY: 10, ctrlKey: true }, rule: "timeline.horizontal-zoom", prevented: true }, + { init: { deltaY: 10, ctrlKey: true, shiftKey: true }, rule: "timeline.waveform-amplitude", prevented: true }, + { init: { deltaY: 10, altKey: true }, rule: "timeline.track-height", prevented: true }, + { init: { deltaY: 10, shiftKey: true }, rule: "timeline.horizontal-scroll", prevented: true }, + { init: { deltaY: 10, ctrlKey: true, altKey: true }, rule: "timeline.horizontal-zoom", prevented: true }, + { init: { deltaY: 10, ctrlKey: true, altKey: true, shiftKey: true }, rule: "timeline.waveform-amplitude", prevented: true }, + { init: { deltaY: 10, metaKey: true }, rule: "timeline.native-scroll", prevented: false }, + ] as const; + + for (const entry of cases) { + const { dispatch, resolved } = await dispatchWheel(page, "wheel-timeline", entry.init); + expect(resolved.ruleId).toBe(entry.rule); + expect(resolved.eventDefaultPrevented).toBe(entry.prevented); + expect(dispatch.defaultPrevented).toBe(entry.prevented); + expect(dispatch.dispatchReturned).toBe(!entry.prevented); + } +}); + +test("timeline wheel precedence maps macOS Command, Option, and Control", async ({ page }) => { + await page.getByLabel("Wheel platform").selectOption("macos"); + const cases = [ + { init: { deltaY: 6, metaKey: true }, rule: "timeline.horizontal-zoom", prevented: true }, + { init: { deltaY: 6, metaKey: true, shiftKey: true }, rule: "timeline.waveform-amplitude", prevented: true }, + { init: { deltaY: 6, altKey: true }, rule: "timeline.track-height", prevented: true }, + { init: { deltaY: 6, ctrlKey: true }, rule: "timeline.native-scroll", prevented: false }, + { init: { deltaY: 6, shiftKey: true }, rule: "timeline.horizontal-scroll", prevented: true }, + { init: { deltaY: 6, metaKey: true, altKey: true }, rule: "timeline.horizontal-zoom", prevented: true }, + { init: { deltaY: 6, metaKey: true, ctrlKey: true }, rule: "timeline.horizontal-zoom", prevented: true }, + ] as const; + + for (const entry of cases) { + const { dispatch, resolved } = await dispatchWheel(page, "wheel-timeline", entry.init); + expect(resolved.ruleId).toBe(entry.rule); + expect(dispatch.defaultPrevented).toBe(entry.prevented); + } +}); + +test("wheel deltas normalize line/page units and preserve native browser scrolling", async ({ page }) => { + await page.getByLabel("Wheel platform").selectOption("windows"); + + const lineZoom = await dispatchWheel(page, "wheel-timeline", { + deltaY: 2, + deltaMode: 1, + ctrlKey: true, + clientX: 123, + clientY: 45, + }); + expect(lineZoom.resolved.ruleId).toBe("timeline.horizontal-zoom"); + expect(lineZoom.resolved.amount).toBe(32); + expect(lineZoom.resolved.anchor).toEqual({ + kind: "pointer", + clientX: 123, + clientY: 45, + targetId: "wheel-timeline", + }); + + const pageScroll = await dispatchWheel(page, "wheel-piano", { + deltaY: 1, + deltaMode: 2, + }); + expect(pageScroll.resolved.ruleId).toBe("piano-roll.dominant-axis-scroll"); + expect(pageScroll.resolved.amount).toBe(800); + expect(pageScroll.resolved.delta).toMatchObject({ y: 800, mode: "page" }); + expect(pageScroll.dispatch.defaultPrevented).toBe(true); + + const nativeBrowser = await dispatchWheel(page, "wheel-browser", { deltaY: 25 }); + expect(nativeBrowser.resolved.ruleId).toBe("browser.native-scroll"); + expect(nativeBrowser.dispatch).toEqual({ dispatchReturned: true, defaultPrevented: false }); + + const protectedBrowser = await dispatchWheel(page, "wheel-browser", { + deltaY: 25, + ctrlKey: true, + }); + expect(protectedBrowser.resolved.ruleId).toBe("browser.suppress-browser-zoom"); + expect(protectedBrowser.dispatch).toEqual({ dispatchReturned: false, defaultPrevented: true }); +}); + +test("parameter wheel owns normal and fine adjustment gestures", async ({ page }) => { + const normal = await dispatchWheel(page, "wheel-parameter", { deltaY: -8 }); + expect(normal.resolved).toMatchObject({ + ruleId: "parameter.adjust", + operation: "adjust", + precision: "normal", + eventDefaultPrevented: true, + }); + + const fine = await dispatchWheel(page, "wheel-parameter", { + deltaY: -8, + shiftKey: true, + }); + expect(fine.resolved).toMatchObject({ + ruleId: "parameter.fine-adjust", + operation: "adjust", + precision: "fine", + eventDefaultPrevented: true, + }); + expect(fine.dispatch).toEqual({ dispatchReturned: false, defaultPrevented: true }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84e3072..6a4e474 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,318 +1,39 @@ { - "name": "studio13-frontend", + "name": "openstudio-frontend", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "studio13-frontend", + "name": "openstudio-frontend", "version": "0.0.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.2.9", - "@tailwindcss/vite": "^4.1.18", "classnames": "^2.5.1", "konva": "^9.3.22", "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-konva": "^19.2.1", - "tailwindcss": "^4.1.18", "zustand": "^5.0.10" }, "devDependencies": { + "@playwright/test": "^1.62.1", + "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^4.2.1", - "sharp": "^0.34.5", + "@vitejs/plugin-react": "^6.0.4", + "sharp": "^0.35.3", + "tailwindcss": "^4.3.3", "typescript": "^5.2.2", - "vite": "^5.0.8", + "vite": "^8.1.5", "vitest": "^4.1.2" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { - "node": ">=6.9.0" + "node": "^22.12.0 || >=24.0.0" } }, "node_modules/@dnd-kit/accessibility": { @@ -332,7 +53,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -370,1069 +90,1405 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, "license": "MIT", "optional": true, "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", + "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, "engines": { - "node": ">=12" + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" + "darwin" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" + "darwin" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ - "ia32" + "ppc64" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ - "loong64" + "riscv64" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ - "mips64el" + "s390x" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ - "ppc64" + "x64" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ - "riscv64" + "arm64" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ - "s390x" + "x64" ], - "license": "MIT", + "dev": true, + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=12" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ - "x64" + "arm" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ - "x64" + "ppc64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=12" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ - "arm64" + "riscv64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=12" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ - "openharmony" + "linux" ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ - "sunos" + "linux" ], "engines": { - "node": ">=12" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ - "arm64" + "wasm32" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0", "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, "engines": { - "node": ">=12" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ - "ia32" + "arm64" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ - "x64" + "ia32" ], - "license": "MIT", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@floating-ui/react": { - "version": "0.26.28", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", - "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.2", - "@floating-ui/utils": "^0.2.8", - "tabbable": "^6.0.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@floating-ui/dom": "^1.7.6" + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@react-aria/focus": { + "version": "3.21.5", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.5.tgz", + "integrity": "sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/interactions": "^3.27.1", + "@react-aria/utils": "^3.33.1", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" }, "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", - "license": "MIT" + "node_modules/@react-aria/interactions": { + "version": "3.27.1", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.1.tgz", + "integrity": "sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-aria/utils": "^3.33.1", + "@react-stately/flags": "^3.1.2", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } }, - "node_modules/@headlessui/react": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", - "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", - "license": "MIT", + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", "dependencies": { - "@floating-ui/react": "^0.26.16", - "@react-aria/focus": "^3.20.2", - "@react-aria/interactions": "^3.25.0", - "@tanstack/react-virtual": "^3.13.9", - "use-sync-external-store": "^1.5.0" + "@swc/helpers": "^0.5.0" }, "engines": { - "node": ">=10" + "node": ">= 12" }, "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "react-dom": "^18 || ^19 || ^19.0.0-rc" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "node_modules/@react-aria/utils": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.1.tgz", + "integrity": "sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==", + "license": "Apache-2.0", + "dependencies": { + "@react-aria/ssr": "^3.9.10", + "@react-stately/flags": "^3.1.2", + "@react-stately/utils": "^3.11.0", + "@react-types/shared": "^3.33.1", + "@swc/helpers": "^0.5.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-stately/flags": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", + "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@react-stately/utils": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz", + "integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.1.tgz", + "integrity": "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "darwin" + "freebsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ - "x64" + "arm" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "darwin" + "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ - "arm" + "arm64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ - "riscv64" + "s390x" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ - "s390x" + "x64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ "linux" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], - "funding": { - "url": "https://opencollective.com/libvips" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ - "x64" + "wasm32" ], "dev": true, - "license": "LGPL-3.0-or-later", + "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ - "arm" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "node": ">= 20" } }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "node": ">= 20" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ - "s390x" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "node": ">= 20" } }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "node": ">= 20" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ - "arm64" + "arm" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "node": ">= 20" } }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">= 20" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">= 20" } }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ - "ia32" + "x64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">= 20" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": ">= 20" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, "license": "MIT", + "optional": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, "license": "MIT", - "engines": { - "node": ">=6.0.0" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, "license": "MIT", + "optional": true, "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "tslib": "^2.4.0" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, "license": "MIT", "optional": true, "dependencies": { @@ -1447,134 +1503,27 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", "dev": true, + "inBundle": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@react-aria/focus": { - "version": "3.21.5", - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.5.tgz", - "integrity": "sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/interactions": { - "version": "3.27.1", - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.1.tgz", - "integrity": "sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.33.1", - "@react-stately/flags": "^3.1.2", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", - "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/utils": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.1.tgz", - "integrity": "sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/ssr": "^3.9.10", - "@react-stately/flags": "^3.1.2", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/flags": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", - "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@react-stately/utils": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz", - "integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==", - "license": "Apache-2.0", + "optional": true, "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/shared": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.1.tgz", - "integrity": "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "tslib": "^2.4.0" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", - "cpu": [ - "arm64" - ], + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "inBundle": true, + "license": "0BSD", + "optional": true }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -1582,16 +1531,16 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -1599,2823 +1548,1146 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", - "cpu": [ - "x64" - ], + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", - "cpu": [ - "arm64" - ], + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", - "cpu": [ - "ppc64" - ], + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", - "cpu": [ - "s390x" - ], + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "csstype": "^3.2.2" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", - "cpu": [ - "x64" - ], + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "peerDependencies": { + "@types/react": "^19.2.0" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@types/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "peerDependencies": { + "@types/react": "*" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", - "cpu": [ - "arm64" - ], + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, "engines": { "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", - "cpu": [ - "wasm32" - ], + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=14.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", - "cpu": [ - "x64" - ], + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", - "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "tailwindcss": "4.2.2" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tanstack/react-virtual": { - "version": "3.13.23", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", - "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.13.23" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.13.23", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", - "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/react-reconciler": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", - "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.2", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.2", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", - "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/its-fine": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", - "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", - "license": "MIT", - "dependencies": { - "@types/react-reconciler": "^0.28.9" - }, - "peerDependencies": { - "react": "^19.0.0" - } - }, - "node_modules/its-fine/node_modules/@types/react-reconciler": { - "version": "0.28.9", - "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", - "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/konva": { - "version": "9.3.22", - "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz", - "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==", - "funding": [ - { - "type": "patreon", - "url": "https://www.patreon.com/lavrton" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/konva" - }, - { - "type": "github", - "url": "https://github.com/sponsors/lavrton" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.562.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", - "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "dev": true, - "license": "MIT" - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://opencollective.com/vitest" } }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=12" } }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/react-konva": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.3.tgz", - "integrity": "sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==", - "funding": [ - { - "type": "patreon", - "url": "https://www.patreon.com/lavrton" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/konva" - }, - { - "type": "github", - "url": "https://github.com/sponsors/lavrton" - } - ], - "license": "MIT", - "dependencies": { - "@types/react-reconciler": "^0.33.0", - "its-fine": "^2.0.0", - "react-reconciler": "0.33.0", - "scheduler": "0.27.0" - }, - "peerDependencies": { - "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "node": ">=18" } }, - "node_modules/react-reconciler": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", - "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^19.2.0" - } + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, - "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "Apache-2.0", + "engines": { + "node": ">=8" } }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" }, "engines": { - "node": ">=10" + "node": ">=10.13.0" } }, - "node_modules/siginfo": { + "node_modules/es-module-lexer": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, - "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT" - }, - "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "license": "MIT" - }, - "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "dependencies": { + "@types/estree": "^1.0.0" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12.0.0" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, "engines": { "node": ">=12.0.0" }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14.0.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" }, - "engines": { - "node": ">=14.17" + "peerDependencies": { + "react": "^19.0.0" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/konva": { + "version": "9.3.22", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz", + "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==", "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" + "type": "patreon", + "url": "https://www.patreon.com/lavrton" }, { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" + "type": "opencollective", + "url": "https://opencollective.com/konva" }, { "type": "github", - "url": "https://github.com/sponsors/ai" + "url": "https://github.com/sponsors/lavrton" } ], - "license": "MIT", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" + "detect-libc": "^2.0.3" }, - "bin": { - "update-browserslist-db": "cli.js" + "engines": { + "node": ">= 12.0.0" }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ - "ppc64" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "aix" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ - "arm" + "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "linux" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "win32" ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "android" + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, "engines": { - "node": ">=18" + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=18" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || >=14" } }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" } }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" + "node_modules/react-konva": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.3.tgz", + "integrity": "sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/react-reconciler": "^0.33.0", + "its-fine": "^2.0.0", + "react-reconciler": "0.33.0", + "scheduler": "0.27.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" } }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "scheduler": "^0.27.0" + }, "engines": { - "node": ">=18" + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" } }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "license": "ISC" }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "BSD-3-Clause", "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { - "node": ">=18" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], "engines": { "node": ">=18" } }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=18" + "node": ">=14.17" } }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", - "dev": true, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.2", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/vitest/node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -4431,8 +2703,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -4482,6 +2754,115 @@ } } }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4499,13 +2880,6 @@ "node": ">=8" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, "node_modules/zustand": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1ac08d8..c084bf1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,12 +1,19 @@ { - "name": "studio13-frontend", + "name": "openstudio-frontend", "private": true, "version": "0.0.1", + "packageManager": "npm@10.9.8", + "engines": { + "node": "^22.12.0 || >=24.0.0" + }, "type": "module", "scripts": { "dev": "vite", "test": "vitest run", - "build": "tsc && vite build", + "test:e2e": "playwright test", + "notices:generate": "node scripts/generate-third-party-notices.mjs", + "notices:check": "node scripts/generate-third-party-notices.mjs --check", + "build": "npm run notices:check && tsc && vite build", "preview": "vite preview" }, "dependencies": { @@ -14,23 +21,24 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.2.9", - "@tailwindcss/vite": "^4.1.18", "classnames": "^2.5.1", "konva": "^9.3.22", "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-konva": "^19.2.1", - "tailwindcss": "^4.1.18", "zustand": "^5.0.10" }, "devDependencies": { + "@playwright/test": "^1.62.1", + "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^4.2.1", - "sharp": "^0.34.5", + "@vitejs/plugin-react": "^6.0.4", + "sharp": "^0.35.3", + "tailwindcss": "^4.3.3", "typescript": "^5.2.2", - "vite": "^5.0.8", + "vite": "^8.1.5", "vitest": "^4.1.2" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..d4d9e8b --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + forbidOnly: true, + retries: 0, + workers: 1, + reporter: "line", + outputDir: "../output/playwright/test-results", + use: { + baseURL: "http://127.0.0.1:5183", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run dev -- --host 127.0.0.1", + url: "http://127.0.0.1:5183", + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/frontend/scripts/generate-third-party-notices.mjs b/frontend/scripts/generate-third-party-notices.mjs new file mode 100644 index 0000000..3b483fe --- /dev/null +++ b/frontend/scripts/generate-third-party-notices.mjs @@ -0,0 +1,278 @@ +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const frontendDirectory = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const lockfilePath = path.join(frontendDirectory, 'package-lock.json'); +const outputPath = path.join(frontendDirectory, 'THIRD_PARTY_NOTICES.txt'); +const nodeModulesDirectory = path.join(frontendDirectory, 'node_modules'); +const noticeFilePattern = /^(?:licen[cs]e|copying|notice)(?:\..+)?$/i; + +function fail(message) { + throw new Error(message); +} + +function normalizeLineEndings(text) { + return text.replace(/\r\n?/g, '\n'); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function formatMetadataValue(value) { + if (typeof value === 'string' && value.trim() !== '') { + return value.trim(); + } + + if (Array.isArray(value) && value.length > 0) { + return value.map(formatMetadataValue).join(' OR '); + } + + if (value && typeof value === 'object') { + if (typeof value.type === 'string' && value.type.trim() !== '') { + return value.type.trim(); + } + return JSON.stringify(value); + } + + return ''; +} + +function packageSource(packageJson, lockEntry) { + const repository = packageJson.repository; + if (typeof repository === 'string' && repository.trim() !== '') { + return repository.trim(); + } + + if (repository && typeof repository === 'object') { + const repositoryUrl = formatMetadataValue(repository.url); + if (repositoryUrl !== '') { + const repositoryDirectory = formatMetadataValue(repository.directory); + return repositoryDirectory === '' + ? repositoryUrl + : `${repositoryUrl} (directory: ${repositoryDirectory})`; + } + } + + const homepage = formatMetadataValue(packageJson.homepage); + if (homepage !== '') { + return homepage; + } + + return formatMetadataValue(lockEntry.resolved); +} + +function assertInsideNodeModules(packageDirectory, lockPath) { + const relativePath = path.relative(nodeModulesDirectory, packageDirectory); + if ( + relativePath === '' + || relativePath.startsWith(`..${path.sep}`) + || relativePath === '..' + || path.isAbsolute(relativePath) + ) { + fail(`Unsafe package path in package-lock.json: ${lockPath}`); + } +} + +function requiredLicenseFilename(declaredLicense) { + if (typeof declaredLicense !== 'string') { + return null; + } + + const match = declaredLicense.match(/^SEE LICEN[CS]E IN (.+)$/i); + return match?.[1]?.trim() || null; +} + +async function loadProductionPackages(lockfile) { + if (lockfile.lockfileVersion !== 3 || !lockfile.packages) { + fail('package-lock.json must use lockfileVersion 3 and contain a packages map.'); + } + + const packages = []; + for (const [lockPath, lockEntry] of Object.entries(lockfile.packages)) { + if ( + lockPath === '' + || !lockPath.startsWith('node_modules/') + || lockEntry.dev === true + ) { + continue; + } + + const packageDirectory = path.resolve(frontendDirectory, ...lockPath.split('/')); + assertInsideNodeModules(packageDirectory, lockPath); + + let packageJson; + try { + packageJson = JSON.parse( + await readFile(path.join(packageDirectory, 'package.json'), 'utf8'), + ); + } catch (error) { + fail(`Missing or invalid installed package metadata for ${lockPath}: ${error.message}`); + } + + const name = formatMetadataValue(packageJson.name); + const version = formatMetadataValue(packageJson.version); + if (name === '' || version === '') { + fail(`Installed package ${lockPath} is missing its name or version.`); + } + if (version !== lockEntry.version) { + fail( + `Installed version mismatch for ${name}: lockfile has ${lockEntry.version}, ` + + `node_modules has ${version}. Run npm ci.`, + ); + } + + const declaredLicense = formatMetadataValue(packageJson.license ?? lockEntry.license); + if (declaredLicense === '') { + fail(`Installed package ${name}@${version} has no declared license.`); + } + + const source = packageSource(packageJson, lockEntry); + if (source === '') { + fail(`Installed package ${name}@${version} has no source URL.`); + } + + let directoryEntries; + try { + directoryEntries = await readdir(packageDirectory, { withFileTypes: true }); + } catch (error) { + fail(`Cannot inspect installed package ${name}@${version}: ${error.message}`); + } + + const noticeFilenames = directoryEntries + .filter((entry) => entry.isFile() && noticeFilePattern.test(entry.name)) + .map((entry) => entry.name) + .sort(compareText); + + const specificallyRequiredLicense = requiredLicenseFilename(packageJson.license); + if ( + specificallyRequiredLicense + && !noticeFilenames.some( + (filename) => filename.toLowerCase() === specificallyRequiredLicense.toLowerCase(), + ) + ) { + fail( + `${name}@${version} declares "${packageJson.license}", but ` + + `${specificallyRequiredLicense} is missing.`, + ); + } + + if (noticeFilenames.length === 0) { + fail(`Installed package ${name}@${version} has no LICENSE, COPYING, or NOTICE file.`); + } + + const notices = []; + for (const filename of noticeFilenames) { + const noticeText = normalizeLineEndings( + await readFile(path.join(packageDirectory, filename), 'utf8'), + ); + if (noticeText.trim() === '') { + fail(`Installed package ${name}@${version} has an empty ${filename} file.`); + } + notices.push({ filename, text: noticeText }); + } + + packages.push({ + declaredLicense, + lockPath, + name, + notices, + source, + version, + }); + } + + packages.sort((left, right) => ( + compareText(left.name, right.name) + || compareText(left.version, right.version) + || compareText(left.lockPath, right.lockPath) + )); + + if (packages.length === 0) { + fail('No production packages were found in package-lock.json.'); + } + + return packages; +} + +function renderNotices(packages) { + const separator = '='.repeat(80); + const lines = [ + 'OpenStudio Frontend Third-Party Notices', + '', + 'This file is generated from frontend/package-lock.json and the exact license,', + 'copying, and notice files installed in frontend/node_modules. Do not edit it', + 'manually; run `npm run notices:generate` after changing production dependencies.', + '', + `Production package instances: ${packages.length}`, + ]; + + for (const packageInfo of packages) { + lines.push( + '', + separator, + `${packageInfo.name}@${packageInfo.version}`, + `Installed path: ${packageInfo.lockPath}`, + `Declared license: ${packageInfo.declaredLicense}`, + `Source: ${packageInfo.source}`, + ); + + for (const notice of packageInfo.notices) { + lines.push( + '', + `--- BEGIN ${notice.filename} (verbatim; line endings normalized to LF) ---`, + notice.text, + `--- END ${notice.filename} ---`, + ); + } + } + + return `${lines.join('\n')}\n`; +} + +async function main() { + const argumentsList = process.argv.slice(2); + const checkOnly = argumentsList.length === 1 && argumentsList[0] === '--check'; + if (argumentsList.length > 0 && !checkOnly) { + fail('Usage: node scripts/generate-third-party-notices.mjs [--check]'); + } + + const lockfile = JSON.parse(await readFile(lockfilePath, 'utf8')); + const packages = await loadProductionPackages(lockfile); + const generatedNotices = renderNotices(packages); + + if (checkOnly) { + let committedNotices; + try { + committedNotices = normalizeLineEndings(await readFile(outputPath, 'utf8')); + } catch (error) { + fail(`Cannot read ${path.basename(outputPath)}: ${error.message}`); + } + + if (committedNotices !== generatedNotices) { + fail( + `${path.basename(outputPath)} is stale. Run npm run notices:generate and commit the result.`, + ); + } + + process.stdout.write( + `${path.basename(outputPath)} is current (${packages.length} production package instances).\n`, + ); + return; + } + + await writeFile(outputPath, generatedNotices, 'utf8'); + process.stdout.write( + `Wrote ${path.basename(outputPath)} (${packages.length} production package instances).\n`, + ); +} + +main().catch((error) => { + process.stderr.write(`Third-party notice generation failed: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/frontend/shortcut-e2e.html b/frontend/shortcut-e2e.html new file mode 100644 index 0000000..44ce0d5 --- /dev/null +++ b/frontend/shortcut-e2e.html @@ -0,0 +1,183 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>OpenStudio shortcut foundations E2E harness + + + +
+

Shortcut and wheel test harness

+ +
+ Keyboard matching + + + +
+ +
+ Native focus owners + + + 0 + +
+ +
+

Active shortcut contexts

+
+
Application
+
Timeline
+
Piano roll
+
Pitch editor
+
Automation
+
+

Active context: application

+

Last shortcut result: none

+
{"timeline":0,"piano_roll":0,"pitch_editor":0}
+
+ +
+

Wheel resolver

+ + +
+
Timeline wheel target
+
Timeline ruler wheel target
+
Timeline track wheel target
+
Timeline clip wheel target
+
Automation lane wheel target
+
Piano-roll wheel target
+
Pitch-editor wheel target
+
+
Browser wheel target
+
+
Parameter wheel target
+
Console fader wheel target
+
Waveform scale target
+
+
none
+
+ +
+

Pointer modifier resolver

+
Clip drag pointer target
+
none
+
+ +
+

Nested editor controls

+
+
+
+
+
+
+
+
+
{"timeline":0,"piano_roll":0}
+
+ +
+

Detached profile snapshot parsing

+ none +
+ +
+

Track meter presentation

+
+
+
+ + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 22b7691..a0d75d1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState, Suspense } fr import { useShallow } from "zustand/shallow"; import { ExternalLink, GripHorizontal, X } from "lucide-react"; import { nativeBridge, type NativeGlobalShortcutEvent } from "./services/NativeBridge"; -import { getGlobalShortcutConflicts } from "./store/actionRegistry"; +import { bootstrapTONE3000Session } from "./services/tone3000Session"; +import { + getGlobalShortcutConflicts, + getRegisteredAction, +} from "./store/actionRegistry"; import { useDAWStore, getEffectiveTrackHeight, @@ -11,20 +15,37 @@ import { getMasterTrackHeaderHeight, } from "./store/useDAWStore"; import { dispatchGlobalShortcut } from "./utils/globalShortcutDispatcher"; +import { + activateShortcutContext, + isEditableShortcutTarget, + isNonTextControlShortcutTarget, +} from "./utils/shortcutContext"; import { installModalContextMenuLeakGuard, shouldSuppressWorkspaceContextMenu, } from "./utils/modalEventGuards"; import { + flushPendingMixerRemoteEdit, publishCurrentMixerUISnapshot, startMixerUISync, } from "./utils/mixerWindowSync"; import { + flushPendingMidiRemoteEdits, publishMidiEditorSessionSnapshot, startMidiEditorUISync, } from "./utils/midiEditorWindowSync"; +import { + applyDetachedMidiQuantizeRequest, + applyDetachedLoopRegionRequest, + executeDetachedMainActionRequest, + isLiveDetachedMidiSessionId, +} from "./utils/detachedMainActionRouting"; import { maybeRunPitchRegressionDriver } from "./utils/pitchRegressionDriver"; import { shouldAutoStopPlayback } from "./utils/transportAutoStop"; +import { getShortcutPlatform } from "./utils/platform"; +import { getMouseBehaviorProfile, toMouseBehaviorPlatform } from "./utils/mouseBehaviorProfiles"; +import { resolveWheelGesture } from "./utils/wheelGestureResolver"; +import { installBrowserZoomWheelGuard } from "./utils/browserWheelGuard"; import { Button } from "./components/ui"; import { Timeline } from "./components/Timeline"; import { TimelineRuler } from "./components/TimelineRuler"; @@ -34,11 +55,13 @@ import { TransportBar as BottomTransportBar } from "./components/TransportBar"; import { MenuBar } from "./components/MenuBar"; import { MasterTrackHeader } from "./components/MasterTrackHeader"; import { ProjectTabBar } from "./components/ProjectTabBar"; -import { CustomToolbarStrip } from "./components/ToolbarEditor"; +import { CustomToolbarStrip, ToolbarEditor } from "./components/ToolbarEditor"; +import { PluginBrowser } from "./components/PluginBrowser"; import { SortableTrackHeader } from "./components/SortableTrackHeader"; import { AddMultipleTracksModal } from "./components/AddMultipleTracksModal"; import { ContextMenu, type MenuItem } from "./components/ContextMenu"; import { EssentialControlsCard } from "./components/EssentialControlsCard"; +import { InputProfileOnboardingCard } from "./components/InputProfileOnboardingCard"; import { UnsavedChangesDialog } from "./components/UnsavedChangesDialog"; import { createMultipleTracks, @@ -70,10 +93,8 @@ const ThemeEditor = React.lazy(() => import("./components/ThemeEditor").then(m = const VideoWindow = React.lazy(() => import("./components/VideoWindow").then(m => ({ default: m.VideoWindow }))); const ScriptEditor = React.lazy(() => import("./components/ScriptEditor").then(m => ({ default: m.ScriptEditor }))); const PitchEditorLowerZone = React.lazy(() => import("./components/PitchEditorLowerZone").then(m => ({ default: m.PitchEditorLowerZone }))); -const ToolbarEditor = React.lazy(() => import("./components/ToolbarEditor").then(m => ({ default: m.ToolbarEditor }))); const DDPExportModal = React.lazy(() => import("./components/DDPExportModal").then(m => ({ default: m.DDPExportModal }))); const ProjectCompareModal = React.lazy(() => import("./components/ProjectCompareModal").then(m => ({ default: m.ProjectCompareModal }))); -const PluginBrowser = React.lazy(() => import("./components/PluginBrowser").then(m => ({ default: m.PluginBrowser }))); const EnvelopeManagerModal = React.lazy(() => import("./components/EnvelopeManagerModal").then(m => ({ default: m.EnvelopeManagerModal }))); const ChannelStripEQModal = React.lazy(() => import("./components/ChannelStripEQModal").then(m => ({ default: m.ChannelStripEQModal }))); const TrackRoutingModal = React.lazy(() => import("./components/TrackRoutingModal").then(m => ({ default: m.TrackRoutingModal }))); @@ -100,6 +121,18 @@ import { function App() { const startupReadyReportedRef = useRef(false); + const autoSaveInFlightRef = useRef(false); + const [isStartupLoading, setIsStartupLoading] = useState(true); + const [startupLoadingMessage, setStartupLoadingMessage] = useState("Preparing OpenStudio..."); + + const reportStartupReady = useCallback((detail: string) => { + if (startupReadyReportedRef.current) { + return; + } + + startupReadyReportedRef.current = true; + window.dispatchEvent(new CustomEvent("openstudio:app-ready", { detail })); + }, []); // Use useShallow to prevent re-renders when unrelated state changes (like currentTime) const { @@ -170,6 +203,7 @@ function App() { showGettingStarted, showMissingMedia, missingMediaFiles, + resolveMissingNAMAsset, masterAutomationLanes, showMasterAutomation, showStemSeparation, @@ -246,6 +280,7 @@ function App() { showGettingStarted: state.showGettingStarted, showMissingMedia: state.showMissingMedia, missingMediaFiles: state.missingMediaFiles, + resolveMissingNAMAsset: state.resolveMissingNAMAsset, masterAutomationLanes: state.masterAutomationLanes, showMasterAutomation: state.showMasterAutomation, showStemSeparation: state.showStemSeparation, @@ -256,32 +291,6 @@ function App() { })) ); - useEffect(() => { - let cancelled = false; - - const markAppReady = (detail: string) => { - if (cancelled || startupReadyReportedRef.current) { - return; - } - - startupReadyReportedRef.current = true; - window.dispatchEvent(new CustomEvent("openstudio:app-ready", { detail })); - }; - - void (async () => { - try { - await hydrateRecentProjects(); - markAppReady("main-app-hydrated"); - } catch (error) { - console.error("[startup] Failed to hydrate recent projects:", error); - markAppReady("main-app-hydration-failed"); - } - })(); - - return () => { - cancelled = true; - }; - }, [hydrateRecentProjects]); // Compute visible tracks — hides children of collapsed folder tracks const visibleTracks = useMemo(() => { @@ -414,8 +423,91 @@ function App() { useEffect(() => startMidiEditorUISync(), []); useEffect(() => { - void refreshAiToolsStatus(true); - }, [refreshAiToolsStatus]); + let cancelled = false; + let finishTimer: number | undefined; + let readyDetail = "main-app-startup-ready"; + const startupGateStartedAt = performance.now(); + + const setStartupStep = (message: string) => { + if (!cancelled) { + setStartupLoadingMessage(message); + } + }; + + const finishStartupGate = () => { + if (cancelled) { + return; + } + + setStartupLoadingMessage("Opening workspace..."); + const remainingMs = Math.max(0, 650 - (performance.now() - startupGateStartedAt)); + finishTimer = window.setTimeout(() => { + if (cancelled) { + return; + } + + setIsStartupLoading(false); + reportStartupReady(readyDetail); + }, remainingMs); + }; + + const openLaunchProjectIfPresent = async () => { + const pendingProjectPath = await nativeBridge.consumePendingLaunchProjectPath(); + if (!pendingProjectPath || cancelled) { + return; + } + + const lowerPath = pendingProjectPath.toLowerCase(); + if (!lowerPath.endsWith(".osproj") && !lowerPath.endsWith(".s13")) { + return; + } + + const launchProjectName = pendingProjectPath.split(/[\\/]/).pop() || "project"; + setStartupStep(`Opening ${launchProjectName}...`); + const success = await useDAWStore.getState().requestOpenProject(pendingProjectPath); + if (!success) { + readyDetail = "main-app-launch-project-failed"; + console.error("[App] Failed to open launch project:", pendingProjectPath); + } + }; + + void (async () => { + try { + setStartupStep("Loading recent projects..."); + await hydrateRecentProjects(); + } catch (error) { + readyDetail = "main-app-startup-recovered"; + console.error("[startup] Failed to hydrate recent projects:", error); + } + + // Network-backed session refresh and optional AI runtime inspection must + // never hold the workspace behind the startup overlay. Both services + // publish their own busy/status state when their background checks finish. + void bootstrapTONE3000Session().catch((error) => { + console.warn("[startup] TONE3000 silent auth bootstrap failed:", error); + }); + void refreshAiToolsStatus(true).catch((error) => { + console.warn("[startup] AI tools status check failed:", error); + }); + + try { + setStartupStep("Checking startup project..."); + await openLaunchProjectIfPresent(); + } catch (error) { + readyDetail = "main-app-startup-recovered"; + console.error("[App] Failed to consume launch project path:", error); + } + + finishStartupGate(); + })(); + + return () => { + cancelled = true; + if (finishTimer !== undefined) { + window.clearTimeout(finishTimer); + } + }; + }, [hydrateRecentProjects, refreshAiToolsStatus, reportStartupReady]); useEffect(() => { const unsubscribe = nativeBridge.onAiToolsStatusUpdate((status) => { @@ -711,12 +803,35 @@ function App() { return; } - const state = useDAWStore.getState(); const command = typeof payload.command === "string" ? payload.command : ""; + const flushDetachedEdits = () => { + flushPendingMixerRemoteEdit(); + flushPendingMidiRemoteEdits(); + }; + if (command === "action.execute") { + executeDetachedMainActionRequest(payload, getRegisteredAction, { + flushPendingEdits: flushDetachedEdits, + }); + return; + } + + if (command === "transport.setLoopRegion") { + applyDetachedLoopRegionRequest(payload); + return; + } + + if (command === "automation.action") { + // Legacy senders are accepted only when they provide the same explicit + // selection payload as action.execute. Target-less packets are unsafe. + executeDetachedMainActionRequest({ + ...payload, + command: "action.execute", + }, getRegisteredAction, { flushPendingEdits: flushDetachedEdits }); + return; + } + + const state = useDAWStore.getState(); const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : ""; - const session = sessionId - ? state.midiEditorSessions.find((candidate) => candidate.sessionId === sessionId) - : null; if (command === "transport.toggle") { if (state.transport.isRecording || state.transport.isPlaying) void state.stop(); @@ -735,12 +850,14 @@ function App() { } if (command === "transport.seek") { + if (sessionId && !isLiveDetachedMidiSessionId(sessionId)) return; const time = typeof payload.time === "number" ? payload.time : state.transport.currentTime; void state.seekTo(Math.max(0, time)); return; } if (command === "transport.seekPreview") { + if (sessionId && !isLiveDetachedMidiSessionId(sessionId)) return; const time = typeof payload.time === "number" ? payload.time : state.transport.currentTime; const clampedTime = Math.max(0, time); state.setCurrentTime(clampedTime); @@ -749,19 +866,20 @@ function App() { } if (command === "edit.undo") { - state.undo(); + flushDetachedEdits(); + useDAWStore.getState().undo(); return; } if (command === "edit.redo") { - state.redo(); + flushDetachedEdits(); + useDAWStore.getState().redo(); return; } if (command === "midi.quantize") { - const targetTrackId = session?.trackId || state.pianoRollTrackId || undefined; - const targetClipId = session?.clipId || state.pianoRollClipId || undefined; - state.quantizeSelectedMIDINotesUsingLast(targetTrackId, targetClipId); + flushDetachedEdits(); + applyDetachedMidiQuantizeRequest(payload); } }); @@ -858,11 +976,6 @@ function App() { if (autoStopDecision.stopTime !== null) { currentState.setCurrentTime(autoStopDecision.stopTime); } - console.log("[App] Auto-stopping silent playback", { - reason: autoStopDecision.reason, - stopTime: autoStopDecision.stopTime, - latestEndTime: autoStopDecision.bounds.latestEndTime, - }); void currentState.stop().catch((error) => { console.warn("[App] Auto-stop silent playback failed", error); autoStopInFlight = false; @@ -874,12 +987,6 @@ function App() { // Loop: wrap back to loopStart when reaching loopEnd const { loopEnabled, loopStart, loopEnd } = currentState.transport; if (loopEnabled && loopEnd > loopStart && newTime >= loopEnd) { - console.log("[App] Playback loop wrap", { - currentTime: currentState.transport.currentTime, - newTime, - loopStart, - loopEnd, - }); newTime = loopStart + (newTime - loopEnd); // Sync backend position on loop wrap nativeBridge.setTransportPosition(newTime); @@ -950,65 +1057,36 @@ function App() { return unsub; }, []); - // Auto-save with rotating backups (Sprint 20.8) + // Auto-save with rotating backups (Sprint 20.8). Preferences expose the + // autoBackup fields, so keep one reactive scheduler driven by that setting. + // The historical autoSave fields remain in persisted state for compatibility + // but no longer create a second competing timer. + const { autoBackupEnabled, autoBackupInterval } = useDAWStore(useShallow((s) => ({ + autoBackupEnabled: s.autoBackupEnabled, + autoBackupInterval: s.autoBackupInterval, + }))); + useEffect(() => { - const state = useDAWStore.getState(); - if (!state.autoBackupEnabled) return; + if (!autoBackupEnabled) return; const interval = setInterval(async () => { const s = useDAWStore.getState(); - if (s.isModified && s.projectPath) { + if (s.isModified + && s.projectPath + && !autoSaveInFlightRef.current) { + autoSaveInFlightRef.current = true; try { - const ok = await s.saveProject(false); - if (ok) console.log("[App] Auto-save completed"); + await s.saveProject(false); } catch { // Auto-save failure is non-critical + } finally { + autoSaveInFlightRef.current = false; } } - }, state.autoBackupInterval); + }, autoBackupInterval); return () => clearInterval(interval); - }, []); - - // Enhanced auto-save: uses autoSaveEnabled / autoSaveIntervalMinutes from store. - // Reactively subscribes to changes so toggling or changing interval takes effect immediately. - useEffect(() => { - const unsubscribe = useDAWStore.subscribe( - (state) => ({ enabled: state.autoSaveEnabled, minutes: state.autoSaveIntervalMinutes }), - ({ enabled, minutes }) => { - // Clear any previous timer first (handled below via closure) - // This subscription just triggers re-evaluation; the actual timer is managed - // by the outer effect dependencies. - void enabled; - void minutes; - }, - { equalityFn: (a, b) => a.enabled === b.enabled && a.minutes === b.minutes }, - ); - return unsubscribe; - }, []); - - // Separate interval effect for the improved auto-save - const autoSaveEnabled = useDAWStore((s) => s.autoSaveEnabled); - const autoSaveIntervalMinutes = useDAWStore((s) => s.autoSaveIntervalMinutes); - - useEffect(() => { - if (!autoSaveEnabled) return; - - const intervalMs = autoSaveIntervalMinutes * 60 * 1000; - const timerId = setInterval(async () => { - const s = useDAWStore.getState(); - if (s.isModified && s.projectPath) { - try { - const ok = await s.saveProject(false); - if (ok) console.log("[App] Auto-save completed"); - } catch { - // Auto-save failure is non-critical - } - } - }, intervalMs); - - return () => clearInterval(timerId); - }, [autoSaveEnabled, autoSaveIntervalMinutes]); + }, [autoBackupEnabled, autoBackupInterval]); // Event-based metering — single batched store update for all tracks + master useEffect(() => { @@ -1019,9 +1097,12 @@ function App() { const trackClipping: Record = data.trackClipping && typeof data.trackClipping === "object" && !Array.isArray(data.trackClipping) ? data.trackClipping : {}; + const midiInputLevels: Record = data.midiInputLevels && typeof data.midiInputLevels === "object" && !Array.isArray(data.midiInputLevels) + ? data.midiInputLevels + : {}; const masterLevel = typeof data.masterLevel === "number" ? data.masterLevel : 0; const masterClipping = data.masterClipping === true; - batchUpdateMeterLevels(trackLevels, masterLevel, trackClipping, masterClipping); + batchUpdateMeterLevels(trackLevels, masterLevel, trackClipping, masterClipping, midiInputLevels); }); }, [batchUpdateMeterLevels]); @@ -1033,36 +1114,12 @@ function App() { }, []); useEffect(() => { - let cancelled = false; - - const openLaunchProject = async () => { - const pendingProjectPath = await nativeBridge.consumePendingLaunchProjectPath(); - if (!pendingProjectPath || cancelled) return; - - const lowerPath = pendingProjectPath.toLowerCase(); - if (!lowerPath.endsWith(".osproj") && !lowerPath.endsWith(".s13")) return; - - try { - const success = await useDAWStore.getState().requestOpenProject(pendingProjectPath); - if (!success) { - console.error("[App] Failed to open launch project:", pendingProjectPath); - } - } catch (error) { - console.error("[App] Failed to consume launch project path:", error); - } - }; - - void openLaunchProject(); - - return () => { - cancelled = true; - }; + activateShortcutContext({ kind: "timeline" }); }, []); // Global keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement | null; void dispatchGlobalShortcut({ key: e.key, code: e.code, @@ -1072,14 +1129,11 @@ function App() { metaKey: e.metaKey, repeat: e.repeat, source: "browser", - targetIsEditable: - !!target && - (target instanceof HTMLInputElement || - target instanceof HTMLSelectElement || - target instanceof HTMLTextAreaElement || - target.isContentEditable), + targetIsEditable: isEditableShortcutTarget(e.target), + targetIsNonTextControl: isNonTextControlShortcutTarget(e.target), preventDefault: () => e.preventDefault(), stopPropagation: () => e.stopPropagation(), + stopImmediatePropagation: () => e.stopImmediatePropagation(), }); }; @@ -1246,21 +1300,11 @@ function App() { return () => ro.disconnect(); }, []); - // Workspace wheel handler — only prevents browser default zoom (Ctrl+scroll). + // App-wide wheel guard — prevents WebView/browser zoom (Ctrl/Cmd+scroll) + // even when focus is in a modal, browser, detached-style panel, or control. // Actual zoom logic is handled by Timeline's RAF-batched handler. useEffect(() => { - const workspace = workspaceRef.current; - if (!workspace) return; - - const handleWheel = (e: WheelEvent) => { - if (e.ctrlKey || e.metaKey || e.altKey) { - // Prevent browser zoom / native scroll — let Timeline handle the rest - e.preventDefault(); - } - }; - - workspace.addEventListener("wheel", handleWheel, { passive: false, capture: true }); - return () => workspace.removeEventListener("wheel", handleWheel, { capture: true }); + return installBrowserZoomWheelGuard(document); }, []); const [showAddMultipleTracksModal, setShowAddMultipleTracksModal] = @@ -1415,7 +1459,17 @@ function App() { /> )} -
+
activateShortcutContext({ kind: "timeline" })} + onContextMenuCapture={() => activateShortcutContext({ kind: "timeline" })} + onFocusCapture={() => activateShortcutContext({ kind: "timeline" })} + data-shortcut-context="timeline" + > +
@@ -1441,14 +1495,23 @@ function App() { useDAWStore.getState().deselectAllTracks(); } }} onWheel={(e) => { - // Alt+scroll to resize track height (mirrors Timeline behavior) - if (e.altKey) { - e.preventDefault(); - e.stopPropagation(); + const shortcutPlatform = getShortcutPlatform(); + const behaviorProfile = getMouseBehaviorProfile( + useDAWStore.getState().mouseBehaviorProfileId, + shortcutPlatform, + ); + const gesture = resolveWheelGesture(e, { + surface: "tcp", + subtarget: "track", + platform: toMouseBehaviorPlatform(shortcutPlatform), + }, behaviorProfile.wheel); + if (gesture.preventDefault) e.preventDefault(); + if (gesture.stopPropagation) e.stopPropagation(); + if (gesture.operation === "resize" && gesture.target === "track-height" && gesture.amount !== 0) { const store = useDAWStore.getState(); const curHeight = store.trackHeight; - const delta = e.deltaY > 0 ? 0.9 : 1.1; - store.setTrackHeight(curHeight * delta); + const factor = gesture.amount > 0 ? 0.9 : 1.1; + store.setTrackHeight(curHeight * factor); } }}>
{ @@ -1624,7 +1687,20 @@ function App() { className="shrink-0 min-h-0 bg-neutral-950 border-t border-neutral-700 flex flex-col" style={{ height: lowerZoneHeight }} aria-label="Docked Piano Roll editor" + data-shortcut-context={`piano_roll:${dockedMidiEditorSession.sessionId}`} data-qa="docked-piano-roll" + onPointerDownCapture={() => activateShortcutContext({ + kind: "piano_roll", + sessionId: dockedMidiEditorSession.sessionId, + })} + onContextMenuCapture={() => activateShortcutContext({ + kind: "piano_roll", + sessionId: dockedMidiEditorSession.sessionId, + })} + onFocusCapture={() => activateShortcutContext({ + kind: "piano_roll", + sessionId: dockedMidiEditorSession.sessionId, + })} >
useDAWStore.getState().resolveMissingMedia(originalPath, newPath) } + onResolveNAMAsset={(target, newPath) => resolveMissingNAMAsset(target, newPath)} onResolveAll={() => useDAWStore.getState().closeMissingMedia()} /> @@ -2036,6 +2113,19 @@ function App() { )} + {/* Startup Loading Overlay */} + {isStartupLoading && ( +
+
+ +
+ )} + {/* Project Loading Overlay */} {isProjectLoading && (
diff --git a/frontend/src/MidiEditorWindowApp.tsx b/frontend/src/MidiEditorWindowApp.tsx index 153bdba..d0f8e27 100644 --- a/frontend/src/MidiEditorWindowApp.tsx +++ b/frontend/src/MidiEditorWindowApp.tsx @@ -6,6 +6,10 @@ import { PianoRoll } from "./components/PianoRoll"; import { nativeBridge, type NativeGlobalShortcutEvent } from "./services/NativeBridge"; import { useDAWStore } from "./store/useDAWStore"; import { dispatchGlobalShortcut } from "./utils/globalShortcutDispatcher"; +import { + isEditableShortcutTarget, + isNonTextControlShortcutTarget, +} from "./utils/shortcutContext"; import { installModalContextMenuLeakGuard } from "./utils/modalEventGuards"; import { windowSessionId } from "./utils/windowEnvironment"; import { @@ -14,9 +18,13 @@ import { startMidiEditorUISync, } from "./utils/midiEditorWindowSync"; import { startSharedTransportSync } from "./utils/sharedTransportSync"; +import { installBrowserZoomWheelGuard } from "./utils/browserWheelGuard"; +import { startDetachedInputProfileSync } from "./utils/inputProfileWindowSync"; export default function MidiEditorWindowApp() { const [hydrated, setHydrated] = useState(false); + useEffect(() => installBrowserZoomWheelGuard(document), []); + useEffect(() => startDetachedInputProfileSync(), []); const { tracks, pianoRollTrackId, @@ -66,7 +74,6 @@ export default function MidiEditorWindowApp() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement | null; void dispatchGlobalShortcut({ key: e.key, code: e.code, @@ -76,14 +83,11 @@ export default function MidiEditorWindowApp() { metaKey: e.metaKey, repeat: e.repeat, source: "browser", - targetIsEditable: - !!target && - (target instanceof HTMLInputElement || - target instanceof HTMLSelectElement || - target instanceof HTMLTextAreaElement || - target.isContentEditable), + targetIsEditable: isEditableShortcutTarget(e.target), + targetIsNonTextControl: isNonTextControlShortcutTarget(e.target), preventDefault: () => e.preventDefault(), stopPropagation: () => e.stopPropagation(), + stopImmediatePropagation: () => e.stopImmediatePropagation(), }); }; diff --git a/frontend/src/MixerWindowApp.tsx b/frontend/src/MixerWindowApp.tsx index d48730c..623d889 100644 --- a/frontend/src/MixerWindowApp.tsx +++ b/frontend/src/MixerWindowApp.tsx @@ -1,18 +1,27 @@ import { useEffect, useState } from "react"; +import { useShallow } from "zustand/shallow"; import { MixerPanel } from "./components/MixerPanel"; import { nativeBridge, type NativeGlobalShortcutEvent } from "./services/NativeBridge"; import { useDAWStore } from "./store/useDAWStore"; import { dispatchGlobalShortcut } from "./utils/globalShortcutDispatcher"; +import { + isEditableShortcutTarget, + isNonTextControlShortcutTarget, +} from "./utils/shortcutContext"; import { installModalContextMenuLeakGuard } from "./utils/modalEventGuards"; import { hydrateMixerUISnapshotFromNative, startMixerUISync, } from "./utils/mixerWindowSync"; import { startSharedTransportSync } from "./utils/sharedTransportSync"; +import { installBrowserZoomWheelGuard } from "./utils/browserWheelGuard"; export default function MixerWindowApp() { - const batchUpdateMeterLevels = useDAWStore((state) => state.batchUpdateMeterLevels); + const { batchUpdateMeterLevels } = useDAWStore(useShallow((state) => ({ + batchUpdateMeterLevels: state.batchUpdateMeterLevels, + }))); const [hydrated, setHydrated] = useState(false); + useEffect(() => installBrowserZoomWheelGuard(document), []); useEffect(() => { let cancelled = false; @@ -62,15 +71,20 @@ export default function MixerWindowApp() { !Array.isArray(data.trackClipping) ? data.trackClipping : {}; + const midiInputLevels: Record = + data.midiInputLevels && + typeof data.midiInputLevels === "object" && + !Array.isArray(data.midiInputLevels) + ? data.midiInputLevels + : {}; const masterLevel = typeof data.masterLevel === "number" ? data.masterLevel : 0; const masterClipping = data.masterClipping === true; - batchUpdateMeterLevels(trackLevels, masterLevel, trackClipping, masterClipping); + batchUpdateMeterLevels(trackLevels, masterLevel, trackClipping, masterClipping, midiInputLevels); }); }, [batchUpdateMeterLevels]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement | null; void dispatchGlobalShortcut({ key: e.key, code: e.code, @@ -80,14 +94,11 @@ export default function MixerWindowApp() { metaKey: e.metaKey, repeat: e.repeat, source: "browser", - targetIsEditable: - !!target && - (target instanceof HTMLInputElement || - target instanceof HTMLSelectElement || - target instanceof HTMLTextAreaElement || - target.isContentEditable), + targetIsEditable: isEditableShortcutTarget(e.target), + targetIsNonTextControl: isNonTextControlShortcutTarget(e.target), preventDefault: () => e.preventDefault(), stopPropagation: () => e.stopPropagation(), + stopImmediatePropagation: () => e.stopImmediatePropagation(), }); }; diff --git a/frontend/src/PluginEditorWindowApp.tsx b/frontend/src/PluginEditorWindowApp.tsx new file mode 100644 index 0000000..8209e7f --- /dev/null +++ b/frontend/src/PluginEditorWindowApp.tsx @@ -0,0 +1,127 @@ +import { useEffect, useMemo } from "react"; +import { BuiltInPluginPanel } from "./components/BuiltInPluginPanel"; +import { + nativeBridge, + type BuiltInPluginAddress, + type NativeGlobalShortcutEvent, +} from "./services/NativeBridge"; +import { bootstrapTONE3000Session } from "./services/tone3000Session"; +import { dispatchGlobalShortcut } from "./utils/globalShortcutDispatcher"; +import { installModalContextMenuLeakGuard } from "./utils/modalEventGuards"; +import { + isEditableShortcutTarget, + isNonTextControlShortcutTarget, +} from "./utils/shortcutContext"; +import { startSharedTransportSync } from "./utils/sharedTransportSync"; +import { windowSessionId } from "./utils/windowEnvironment"; +import { installBrowserZoomWheelGuard } from "./utils/browserWheelGuard"; +import { startDetachedInputProfileSync } from "./utils/inputProfileWindowSync"; +import "./components/FXChainPanel.css"; + +type BuiltInPluginEditorSession = { + address?: BuiltInPluginAddress; + title?: string; + fallbackName?: string; +}; + +function parseSession(): BuiltInPluginEditorSession | null { + if (!windowSessionId) return null; + const candidates = [windowSessionId]; + try { + const decoded = decodeURIComponent(windowSessionId); + if (decoded !== windowSessionId) candidates.push(decoded); + } catch { + // Keep the raw session candidate. + } + + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate) as BuiltInPluginEditorSession; + if (!parsed.address || !parsed.address.chain) continue; + return parsed; + } catch { + // Try the next representation. + } + } + + return null; +} + +export default function PluginEditorWindowApp() { + useEffect(() => installBrowserZoomWheelGuard(document), []); + useEffect(() => startDetachedInputProfileSync(), []); + const session = useMemo(parseSession, []); + const title = session?.fallbackName || session?.title || "OpenStudio Plugin"; + + useEffect(() => { + return startSharedTransportSync(); + }, []); + + useEffect(() => { + void bootstrapTONE3000Session().catch((error) => { + console.warn("[pluginEditor] TONE3000 silent auth bootstrap failed:", error); + }); + }, []); + + useEffect(() => installModalContextMenuLeakGuard(), []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + void dispatchGlobalShortcut({ + key: e.key, + code: e.code, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + repeat: e.repeat, + source: "browser", + targetIsEditable: isEditableShortcutTarget(e.target), + targetIsNonTextControl: isNonTextControlShortcutTarget(e.target), + preventDefault: () => e.preventDefault(), + stopPropagation: () => e.stopPropagation(), + stopImmediatePropagation: () => e.stopImmediatePropagation(), + }); + }; + + window.addEventListener("keydown", handleKeyDown, true); + const unsubscribeNativeShortcuts = nativeBridge.onNativeGlobalShortcut( + (event: NativeGlobalShortcutEvent) => { + void dispatchGlobalShortcut({ ...event, source: "pluginWindow" }); + }, + ); + + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + unsubscribeNativeShortcuts(); + }; + }, []); + + if (!session?.address) { + return ( +
+
+
+ OpenStudio plugin editor unavailable + The editor window did not receive a valid plugin session. +
+
+
+ ); + } + + return ( +
+ { + void nativeBridge.closeBuiltInPluginEditorWindow( + windowSessionId, + "close", + ); + }} + /> +
+ ); +} diff --git a/frontend/src/__tests__/actionCatalogIntegrity.test.ts b/frontend/src/__tests__/actionCatalogIntegrity.test.ts new file mode 100644 index 0000000..1b85115 --- /dev/null +++ b/frontend/src/__tests__/actionCatalogIntegrity.test.ts @@ -0,0 +1,569 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + executeActiveScopedAction, + getActionShortcutConflicts, + getActionShortcutScopes, + getDeferredActions, + getRegisteredAction, + getRegisteredActions, + registerScopedActionExecutor, + type ActionShortcutScope, +} from "../store/actionRegistry"; +import { useDAWStore } from "../store/useDAWStore"; +import { activateShortcutContext, type EditShortcutContext } from "../utils/shortcutContext"; + +const expectedExecutableInventory = [ + "file.saveNewVersion", + "file.openRecent", + "file.clearRecentProjects", + "file.loadTemplate", + "file.deleteTemplate", + "help.checkForUpdates", + "help.about", + "view.renderQueue", + "view.clipLauncher", + "view.stepSequencer", + "view.scriptConsole", + "view.aiToolsSetup", + "view.customToolbar", + "view.bigClockFormat", + "view.gridType.bar", + "view.gridType.beat", + "view.gridType.use-quantize", + "view.gridType.adapt-to-zoom", + "options.timecodeSettings", + "options.toggleItemLock", + "options.toggleEnvelopeLock", + "options.toggleTimeSelectionLock", + "insert.multipleTracks", + "insert.emptyMidiClip", + "track.selectAll", + "track.deselectAll", + "track.deleteSelected", + "track.toggleSelectedMute", + "track.toggleSelectedSolo", + "track.duplicateSelected", + "track.toggleSelectedArm", + "track.linkSelected", + "track.unlinkSelected", + "track.setSelectedColor", + "track.consolidateSelected", + "track.toggleSelectedFxBypass", + "track.toggleSelectedMonitor", + "track.toggleSelectedAutomationRead", + "track.toggleSelectedAutomationWrite", + "track.toggleSelectedPhaseInvert", + "track.moveSelectedToFolder", + "track.removeSelectedFromFolder", + "track.toggleSelectedFolders", + "track.toggleSelectedAutomation", + "track.toggleSelectedSpectralView", + "track.toggleSelectedFreeze", + "track.renderSelectedInPlace", + "track.saveSelectedAsTemplate", + "track.loadTemplate", + "track.openSelectedEnvelopeManager", + "track.openSelectedRouting", + "track.openSelectedPluginBrowser", + "track.openSelectedChannelEQ", + "clip.openSelectedInPianoRoll", + "clip.repeatSelected", + "clip.setSelectedColor", + "clip.resetSelectedMidiSourceOffset", + "clip.setSelectedMidiSourceLengthToItem", + "clip.setSelectedMidiSourceLengthToContent", + "clip.setSelectedMidiSourceLength", + "clip.humanizeSelectedMidi", + "clip.exportSelectedMidi", + "clip.renderSelectedInPlace", + "clip.separateSelectedStems", + "clip.createAIVariation", + "clip.inpaintSelection", + "clip.continueSelectedWithAI", + "midi.toggleStepInput", + "midi.toggleAudition", + "midi.detachEditor", + "midi.dockEditor", + "midi.invertSelection", + "midi.selectSamePitch", + "midi.humanizeSelected", + "midi.setSelectedVelocity", + "midi.randomizeSelectedVelocity", + "midi.setSelectedLength", + "midi.legatoSelected", + "midi.reverseSelected", + "midi.invertSelectedPitches", + "midi.mirrorSelectedPitches", + "midi.toggleSelectedMute", + "midi.cropClipToSelected", + "midi.insertChord", + "mixer.recallSnapshot", + "mixer.deleteSnapshot", + "mixer.toggleMasterMute", + "mixer.toggleMasterMono", + "mixer.detach", + "mixer.attach", + "track.openSelectedFxChain", + "clip.splitAtPointer", + "midi.loopFromSelectedNotes", + "midi.noteProperties", + "midi.configureControllerLanes", + "midi.toggleGhostReference", + "pitch.detectKeyScale", + "pitch.correctAllToScale", + "pitch.toggleAB", + "fx.removeSelected", + "fx.toggleSelectedBypass", + "fx.openSelectedEditor", + "fx.add", +] as const; + +const expectedDeferredInventory = [] as const; + +/** + * Visible buttons and menu/context-menu commands added by the final surface + * audit. Keeping the owning surface beside each id proves that a command is + * not merely in the palette: it can be discovered by the dispatcher while + * that part of the DAW owns keyboard focus. + */ +const auditedVisibleCommandSurfaces: ReadonlyArray<{ + scope: ActionShortcutScope; + actionIds: readonly string[]; +}> = [ + { + scope: "global", + actionIds: [ + "transport.pause", + "transport.metronome", + "transport.metronomeSettings", + "view.cycleTimecodeMode", + "view.openGridQuantizePanel", + "edit.applyCurrentQuantize", + "options.saveQuantizePreset", + "options.renameQuantizePreset", + "options.removeQuantizePreset", + "options.restoreFactoryQuantizePresets", + "options.themeReaperGray", + ], + }, + { + scope: "timeline", + actionIds: [ + "clip.quantizeSelectedMidi", + "clip.humanizeSelectedMidi", + "clip.transposeSelectedMidiUp", + "clip.transposeSelectedMidiDown", + "clip.transposeSelectedMidiOctaveUp", + "clip.transposeSelectedMidiOctaveDown", + "clip.setSelectedMidiVelocity", + "clip.increaseSelectedMidiVelocity", + "clip.decreaseSelectedMidiVelocity", + ], + }, + { + scope: "track_control_panel", + actionIds: [ + "automation.showAllSelectedTrackEnvelopes", + "automation.hideAllSelectedTrackEnvelopes", + "track.clearSelectedSamplerSample", + "track.removeSelectedInstrument", + "track.openSelectedNotes", + "track.loadSelectedSamplerSample", + ], + }, + { + scope: "mixer", + actionIds: [ + "mixer.toggleMasterAutomationRead", + "mixer.toggleMasterAutomationWrite", + "mixer.toggleMasterAutomationLanes", + "mixer.openMasterEnvelopeManager", + "mixer.openMasterFxChain", + "mixer.addMonitorFx", + ], + }, + { + scope: "piano_roll", + actionIds: [ + "midi.openQuantizePanel", + "midi.quantizeLength", + "midi.controllerLine", + "midi.controllerSineLfo", + "midi.controllerTriangleLfo", + "midi.controllerSquareLfo", + "midi.controllerSawUpLfo", + "midi.controllerSawDownLfo", + "midi.controllerTransform", + "midi.controllerThin", + "midi.copyControllerLane", + "midi.pasteControllerLane", + "midi.clearControllerLane", + ], + }, + { scope: "pitch_editor", actionIds: ["pitch.openCorrectionMacro"] }, + { + scope: "plugin", + actionIds: [ + "fx.toggleSelectedAB", + "fx.reloadSelectedScript", + "fx.toggleSelectedParameters", + "fx.toggleSelectedPresets", + "fx.openInstrumentEditor", + "fx.removeInstrument", + ], + }, + { + scope: "browser", + actionIds: [ + "browser.focusSearch", + "browser.toggleFavorites", + "browser.openUserEffectsFolder", + "browser.toggleScanFolders", + "browser.addScanFolder", + "browser.scanPlugins", + "browser.deepScanPlugins", + "browser.removeCurrentInstrument", + "browser.mediaNavigateUp", + "browser.mediaToggleRecent", + "browser.mediaFocusFilter", + ], + }, +]; + +const originalState = { + tracks: useDAWStore.getState().tracks, + selectedTrackId: useDAWStore.getState().selectedTrackId, + selectedTrackIds: useDAWStore.getState().selectedTrackIds, + trackGroups: useDAWStore.getState().trackGroups, + toggleRenderQueue: useDAWStore.getState().toggleRenderQueue, + toggleTrackMute: useDAWStore.getState().toggleTrackMute, + toggleTrackSolo: useDAWStore.getState().toggleTrackSolo, + toggleTrackArmed: useDAWStore.getState().toggleTrackArmed, + toggleTrackFXBypass: useDAWStore.getState().toggleTrackFXBypass, + toggleSelectedTracksMute: useDAWStore.getState().toggleSelectedTracksMute, + toggleSelectedTracksSolo: useDAWStore.getState().toggleSelectedTracksSolo, + toggleSelectedTracksArmed: useDAWStore.getState().toggleSelectedTracksArmed, + toggleSelectedTracksFXBypass: useDAWStore.getState().toggleSelectedTracksFXBypass, + unlinkTracksFromGroups: useDAWStore.getState().unlinkTracksFromGroups, + addTrackGroup: useDAWStore.getState().addTrackGroup, +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + activateShortcutContext({ kind: "application" }); + useDAWStore.setState(originalState); +}); + +describe("action catalog integrity", () => { + it("has unique stable ids across executable and deferred catalogs", () => { + const executableIds = getRegisteredActions().map((action) => action.id); + const deferredIds = getDeferredActions().map((action) => action.id); + const allIds = [...executableIds, ...deferredIds]; + + expect(new Set(executableIds).size).toBe(executableIds.length); + expect(new Set(deferredIds).size).toBe(deferredIds.length); + expect(new Set(allIds).size).toBe(allIds.length); + }); + + it("gives every executable action a real implementation, category and explicit scope", () => { + for (const action of getRegisteredActions()) { + expect(action.name.trim(), action.id).not.toBe(""); + expect(action.category.trim(), action.id).not.toBe(""); + expect(action.shortcutScope, action.id).toBeTruthy(); + expect(getActionShortcutScopes(action).length, action.id).toBeGreaterThan(0); + expect(action.execute, action.id).toBeTypeOf("function"); + expect(action.execute.toString().replace(/\s/g, ""), action.id).not.toMatch(/^(\(\))?=>\{\}$/); + } + }); + + it("contains the audited executable command inventory", () => { + const ids = new Set(getRegisteredActions().map((action) => action.id)); + for (const actionId of expectedExecutableInventory) { + expect(ids.has(actionId), actionId).toBe(true); + } + }); + + it("registers every audited visible command in a dispatcher-reachable surface", () => { + for (const { scope, actionIds } of auditedVisibleCommandSurfaces) { + for (const actionId of actionIds) { + const action = getRegisteredAction(actionId); + expect(action, actionId).toBeDefined(); + expect(getActionShortcutScopes(action!), actionId).toContain(scope); + } + } + }); + + it("keeps unsafe/local commands visible as truthful non-executable metadata", () => { + const deferred = new Map(getDeferredActions().map((action) => [action.id, action])); + expect(deferred.size).toBe(expectedDeferredInventory.length); + for (const actionId of expectedDeferredInventory) { + const action = deferred.get(actionId); + expect(action, actionId).toBeDefined(); + expect(action?.reasonDetail.trim(), actionId).not.toBe(""); + expect(getRegisteredAction(actionId), actionId).toBeUndefined(); + } + expect(getRegisteredAction("track.toggleSelectedArm")).toMatchObject({ + shortcut: "R", + shortcutScope: "track_control_panel", + }); + expect(getDeferredActions().filter((action) => action.reason === "requires_undo_support")) + .toEqual([]); + }); + + it("has no invalid same-scope factory shortcut conflicts", () => { + expect(getActionShortcutConflicts()).toEqual([]); + }); + + it("declares editor mode conditions for intentional Piano Roll key reuse", () => { + expect(getRegisteredAction("midi.tool.draw")?.shortcutWhen).toBe("step_input_disabled"); + expect(getRegisteredAction("midi.stepInputD")?.shortcutWhen).toBe("step_input_enabled"); + }); + + it("executes selected-track actions against the current multi-selection", () => { + const toggleRenderQueue = vi.fn(); + const toggleSelectedTracksMute = vi.fn(); + const toggleSelectedTracksSolo = vi.fn(); + useDAWStore.setState({ + selectedTrackId: "track-a", + selectedTrackIds: ["track-a", "track-b"], + toggleRenderQueue, + toggleSelectedTracksMute, + toggleSelectedTracksSolo, + }); + + getRegisteredAction("view.renderQueue")?.execute(); + getRegisteredAction("track.toggleSelectedMute")?.execute(); + getRegisteredAction("track.toggleSelectedSolo")?.execute(); + + expect(toggleRenderQueue).toHaveBeenCalledTimes(1); + expect(toggleSelectedTracksMute).toHaveBeenCalledTimes(1); + expect(toggleSelectedTracksSolo).toHaveBeenCalledTimes(1); + }); + + it("routes linked selected-track controls through one selection transaction", () => { + const toggleSelectedTracksMute = vi.fn(); + const toggleSelectedTracksSolo = vi.fn(); + const toggleSelectedTracksArmed = vi.fn(); + const toggleSelectedTracksFXBypass = vi.fn(); + useDAWStore.setState({ + tracks: [ + { id: "track-a", armed: false, recordSafe: false }, + { id: "track-b", armed: false, recordSafe: false }, + { id: "track-c", armed: false, recordSafe: false }, + ] as never, + selectedTrackId: "track-a", + selectedTrackIds: ["track-a", "track-b", "track-c"], + trackGroups: [{ + id: "group-a", + name: "Linked pair", + leadTrackId: "track-a", + memberTrackIds: ["track-a", "track-b"], + linkedParams: ["mute", "solo", "armed", "fxBypass"], + }], + toggleSelectedTracksMute, + toggleSelectedTracksSolo, + toggleSelectedTracksArmed, + toggleSelectedTracksFXBypass, + }); + + getRegisteredAction("track.toggleSelectedMute")?.execute(); + getRegisteredAction("track.toggleSelectedSolo")?.execute(); + getRegisteredAction("track.toggleSelectedArm")?.execute(); + getRegisteredAction("track.toggleSelectedFxBypass")?.execute(); + + expect(toggleSelectedTracksMute).toHaveBeenCalledTimes(1); + expect(toggleSelectedTracksSolo).toHaveBeenCalledTimes(1); + expect(toggleSelectedTracksArmed).toHaveBeenCalledTimes(1); + expect(toggleSelectedTracksFXBypass).toHaveBeenCalledTimes(1); + }); + + it("routes link and unlink commands through selection-level store transactions", () => { + const addTrackGroup = vi.fn(); + const unlinkTracksFromGroups = vi.fn(); + useDAWStore.setState({ + tracks: [ + { id: "track-a" }, + { id: "track-b" }, + ] as never, + selectedTrackId: "track-a", + selectedTrackIds: ["track-a", "track-b"], + trackGroups: [], + addTrackGroup, + unlinkTracksFromGroups, + }); + + getRegisteredAction("track.linkSelected")?.execute(); + expect(addTrackGroup).toHaveBeenCalledWith( + "Group", + "track-a", + ["track-a", "track-b"], + ["volume", "pan", "mute", "solo", "armed", "fxBypass"], + ); + + useDAWStore.setState({ + trackGroups: [{ + id: "group-a", + name: "Group", + leadTrackId: "track-a", + memberTrackIds: ["track-a", "track-b"], + linkedParams: ["mute"], + }], + }); + getRegisteredAction("track.unlinkSelected")?.execute(); + expect(unlinkTracksFromGroups).toHaveBeenCalledWith(["track-a", "track-b"]); + }); + + it("routes component-owned catalog actions through the exact active scoped executor", () => { + const cases: Array<{ + context: EditShortcutContext; + actionIds: readonly string[]; + }> = [ + { context: { kind: "timeline" }, actionIds: ["clip.splitAtPointer"] }, + { + context: { kind: "piano_roll", sessionId: "catalog-test" }, + actionIds: [ + "midi.loopFromSelectedNotes", + "midi.noteProperties", + "midi.toggleGhostReference", + "midi.openQuantizePanel", + "midi.quantizeLength", + "midi.controllerLine", + "midi.controllerSineLfo", + "midi.controllerTriangleLfo", + "midi.controllerSquareLfo", + "midi.controllerSawUpLfo", + "midi.controllerSawDownLfo", + "midi.controllerTransform", + "midi.controllerThin", + "midi.copyControllerLane", + "midi.pasteControllerLane", + "midi.clearControllerLane", + ], + }, + { + context: { kind: "pitch_editor" }, + actionIds: ["pitch.detectKeyScale", "pitch.correctAllToScale", "pitch.toggleAB", "pitch.openCorrectionMacro"], + }, + { + context: { kind: "track_control_panel" }, + actionIds: [ + "track.openSelectedFxChain", + "track.openSelectedNotes", + "track.loadSelectedSamplerSample", + "mixer.openMasterFxChain", + ], + }, + { + context: { kind: "mixer" }, + actionIds: ["track.openSelectedFxChain", "mixer.openMasterFxChain", "mixer.addMonitorFx"], + }, + { + context: { kind: "plugin", sessionId: "catalog-test" }, + actionIds: [ + "fx.removeSelected", + "fx.toggleSelectedBypass", + "fx.openSelectedEditor", + "fx.toggleSelectedAB", + "fx.reloadSelectedScript", + "fx.toggleSelectedParameters", + "fx.toggleSelectedPresets", + "fx.openInstrumentEditor", + "fx.removeInstrument", + ], + }, + { + context: { kind: "browser" }, + actionIds: [ + "browser.focusSearch", + "browser.toggleFavorites", + "browser.openUserEffectsFolder", + "browser.toggleScanFolders", + "browser.addScanFolder", + "browser.scanPlugins", + "browser.deepScanPlugins", + "browser.removeCurrentInstrument", + "browser.mediaNavigateUp", + "browser.mediaToggleRecent", + "browser.mediaFocusFilter", + ], + }, + ]; + + for (const { context, actionIds } of cases) { + const executor = vi.fn((_actionId: string) => "handled" as const); + const unregister = registerScopedActionExecutor(context, executor); + activateShortcutContext(context); + try { + for (const actionId of actionIds) getRegisteredAction(actionId)?.execute(); + expect(executor.mock.calls.map(([actionId]) => actionId)).toEqual(actionIds); + } finally { + unregister(); + } + } + + expect(executeActiveScopedAction("fx.removeSelected")).toBe("unmatched"); + }); + + it("keeps shell-owned global commands reachable after an editor takes focus", () => { + const applicationExecutor = vi.fn((actionId: string) => ( + actionId === "transport.metronomeSettings" + || actionId === "view.openGridQuantizePanel" + || actionId === "edit.applyCurrentQuantize" + ? "handled" as const + : "unmatched" as const + )); + const timelineExecutor = vi.fn(() => "unmatched" as const); + const unregisterApplication = registerScopedActionExecutor( + { kind: "application" }, + applicationExecutor, + ); + const unregisterTimeline = registerScopedActionExecutor( + { kind: "timeline" }, + timelineExecutor, + ); + activateShortcutContext({ kind: "timeline" }); + + try { + getRegisteredAction("transport.metronomeSettings")?.execute(); + getRegisteredAction("view.openGridQuantizePanel")?.execute(); + getRegisteredAction("edit.applyCurrentQuantize")?.execute(); + expect(applicationExecutor.mock.calls.map(([actionId]) => actionId)).toEqual([ + "transport.metronomeSettings", + "view.openGridQuantizePanel", + "edit.applyCurrentQuantize", + ]); + } finally { + unregisterTimeline(); + unregisterApplication(); + } + }); + + it("falls through co-owned surface executors until one recognizes the action", () => { + const owner = vi.fn(() => "handled" as const); + const unrelatedOwner = vi.fn(() => "unmatched" as const); + const unregisterOwner = registerScopedActionExecutor({ kind: "browser" }, owner); + const unregisterUnrelated = registerScopedActionExecutor({ kind: "browser" }, unrelatedOwner); + activateShortcutContext({ kind: "browser" }); + + try { + expect(executeActiveScopedAction("browser.mediaNavigateUp")).toBe("handled"); + expect(unrelatedOwner).toHaveBeenCalledWith("browser.mediaNavigateUp"); + expect(owner).toHaveBeenCalledWith("browser.mediaNavigateUp"); + } finally { + unregisterUnrelated(); + unregisterOwner(); + } + }); + + it("exposes track commands in both TCP and Mixer without widening them globally", () => { + expect(getActionShortcutScopes(getRegisteredAction("track.toggleSelectedMute")!)) + .toEqual(["track_control_panel", "mixer"]); + expect(getActionShortcutScopes(getRegisteredAction("track.renderSelectedInPlace")!)) + .toEqual(["track_control_panel", "mixer", "timeline"]); + expect(getActionShortcutScopes(getRegisteredAction("track.toggleSelectedAutomationRead")!)) + .toEqual(["automation", "track_control_panel", "mixer"]); + expect(getActionShortcutScopes(getRegisteredAction("track.toggleSelectedFxBypass")!)) + .toEqual(["track_control_panel", "mixer", "plugin"]); + }); +}); diff --git a/frontend/src/__tests__/aiGenerationStore.test.ts b/frontend/src/__tests__/aiGenerationStore.test.ts index 2851a85..e1c90ec 100644 --- a/frontend/src/__tests__/aiGenerationStore.test.ts +++ b/frontend/src/__tests__/aiGenerationStore.test.ts @@ -38,7 +38,7 @@ describe("AI generation store actions", () => { vi.spyOn(nativeBridge, "addTrack").mockResolvedValue("track-added"); vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); vi.spyOn(nativeBridge, "addPlaybackClip").mockResolvedValue(true); - vi.spyOn(nativeBridge, "removePlaybackClip").mockResolvedValue(true); + vi.spyOn(nativeBridge, "removePlaybackClipById").mockResolvedValue(true); vi.spyOn(nativeBridge, "refreshWaveformPeaks").mockResolvedValue(true); }); diff --git a/frontend/src/__tests__/aiToolsFeatureInstaller.test.ts b/frontend/src/__tests__/aiToolsFeatureInstaller.test.ts index 4330a8e..e097b1e 100644 --- a/frontend/src/__tests__/aiToolsFeatureInstaller.test.ts +++ b/frontend/src/__tests__/aiToolsFeatureInstaller.test.ts @@ -39,7 +39,11 @@ describe("AI feature installer contract", () => { expect(modalSource).toContain("STABLE_AUDIO_MODEL_URL"); expect(modalSource).toContain("Open Hugging Face Model Page"); expect(modalSource).toContain("Proceed with Setup"); - expect(modalSource).toContain("Use Downloads Folder"); + expect(modalSource).toContain("STABLE_AUDIO_FOLDER_EXAMPLE"); + expect(modalSource).toContain("nativeBridge.browseForFolder"); + expect(modalSource).not.toContain("C:\\\\Users\\\\"); + expect(modalSource).not.toContain("srvds"); + expect(modalSource).not.toContain("Use Downloads Folder"); expect(modalSource).toContain("Cancel Setup"); expect(modalSource).toContain("stableAudioSelectedFolder"); expect(modalSource).toContain("modelId: STABLE_AUDIO_3_MODEL_ID"); diff --git a/frontend/src/__tests__/atomicShortcutMutations.test.ts b/frontend/src/__tests__/atomicShortcutMutations.test.ts new file mode 100644 index 0000000..8d1c3c0 --- /dev/null +++ b/frontend/src/__tests__/atomicShortcutMutations.test.ts @@ -0,0 +1,1321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { nativeBridge } from "../services/NativeBridge"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + type MidiEditorSession, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function audioClip(id: string, overrides: Partial = {}): AudioClip { + return { + id, + filePath: `C:/audio/${id}.wav`, + name: id, + startTime: 0, + duration: 1, + offset: 0, + color: "#224466", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + ...overrides, + }; +} + +function midiClip(id: string, overrides: Partial = {}): MIDIClip { + return { + id, + name: id, + startTime: 0, + duration: 1, + sourceLength: 1, + loopLength: 1, + events: [], + ccEvents: [], + color: "#664422", + ...overrides, + }; +} + +function midiSession(clipId: string): MidiEditorSession { + return { + sessionId: `session-${clipId}`, + trackId: "midi", + clipId, + mode: "docked", + selectedNoteIds: ["note-a"], + midiEditRange: { startTime: 0, endTime: 1, minNote: 36, maxNote: 84, includeCC: true }, + editCursorTime: 0.5, + activeTool: "select", + visibleLanes: [], + activeLaneId: "velocity", + scrollY: 0, + windowPixelsPerSecond: 100, + windowScrollX: 0, + openedAt: 1, + updatedAt: 1, + }; +} + +function resetProject() { + commandManager.clear(); + useDAWStore.setState({ + tracks: [], + trackGroups: [], + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + selectedClipId: null, + selectedClipIds: [], + selectedNoteIds: [], + selectedAutomationTarget: null, + selectedRegionIds: [], + timeSelection: null, + razorEdits: [], + midiEditRange: null, + pianoRollEditCursorTime: null, + midiEditorSessions: [], + activeMidiEditorSessionId: null, + dockedMidiEditorSessionId: null, + detachedPanels: [], + showPianoRoll: false, + pianoRollTrackId: null, + pianoRollClipId: null, + showPitchEditor: false, + pitchEditorTrackId: null, + pitchEditorClipId: null, + pitchEditorFxIndex: 0, + rippleMode: "off", + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + canUndo: false, + canRedo: false, + syncClipsWithBackend: vi.fn(async () => undefined), + syncMIDITrackToBackend: vi.fn(async () => undefined), + }); +} + +beforeEach(resetProject); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("atomic Timeline shortcut mutations", () => { + it("deselects zero-time cursors and selection stored in every MIDI editor session", () => { + const session = { + ...midiSession("midi-clip"), + selectedNoteIds: ["session-note"], + editCursorTime: 0, + }; + useDAWStore.setState({ + selectedNoteIds: [], + midiEditRange: null, + pianoRollEditCursorTime: 0, + midiEditorSessions: [session], + }); + + const action = getRegisteredAction("edit.deselectAll")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + + const state = useDAWStore.getState(); + expect(state.pianoRollEditCursorTime).toBeNull(); + expect(state.midiEditorSessions[0].selectedNoteIds).toEqual([]); + expect(state.midiEditorSessions[0].midiEditRange).toBeNull(); + expect(state.midiEditorSessions[0].editCursorTime).toBeNull(); + }); + + it("keeps a selected track safe when the higher-priority clip selection is locked", () => { + const track = createDefaultTrack("audio", "Audio", "#111", "audio", []); + track.clips = [audioClip("locked-clip", { locked: true })]; + const deleteSelectedTracks = vi.fn(async () => undefined); + useDAWStore.setState({ + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + selectedClipId: "locked-clip", + selectedClipIds: ["locked-clip"], + deleteSelectedTracks, + }); + + const action = getRegisteredAction("edit.delete")!; + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + + expect(useDAWStore.getState().tracks).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual(["locked-clip"]); + expect(deleteSelectedTracks).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("keeps lower-priority clip and track selections safe when razor content is locked", () => { + const track = createDefaultTrack("audio", "Audio", "#111", "audio", []); + track.clips = [ + audioClip("razor-locked", { startTime: 0, duration: 1, locked: true }), + audioClip("lower-priority", { startTime: 2, duration: 1 }), + ]; + const deleteSelectedTracks = vi.fn(async () => undefined); + useDAWStore.setState({ + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + selectedClipId: "lower-priority", + selectedClipIds: ["lower-priority"], + razorEdits: [{ trackId: track.id, start: 0, end: 1 }], + deleteSelectedTracks, + }); + + const action = getRegisteredAction("edit.delete")!; + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual([ + "razor-locked", + "lower-priority", + ]); + expect(deleteSelectedTracks).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("keeps a selected track safe when the higher-priority time selection is locked", () => { + const track = createDefaultTrack("audio", "Audio", "#111", "audio", []); + track.clips = [audioClip("time-target", { startTime: 0, duration: 2 })]; + const deleteSelectedTracks = vi.fn(async () => undefined); + useDAWStore.setState({ + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + timeSelection: { start: 0, end: 1 }, + lockSettings: { items: true, envelopes: false, timeSelection: false, markers: false }, + deleteSelectedTracks, + }); + + const action = getRegisteredAction("edit.delete")!; + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + + expect(useDAWStore.getState().tracks).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual(["time-target"]); + expect(deleteSelectedTracks).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("applies one Global Lock policy to structural track hotkeys in Timeline, TCP, and mixer contexts", async () => { + const first = createDefaultTrack("first", "First", "#111", "audio", []); + const second = createDefaultTrack("second", "Second", "#222", "audio", [first]); + const removeTrack = vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + const addTrack = vi.spyOn(nativeBridge, "addTrack") + .mockImplementation(async (explicitId) => explicitId || "generated-track"); + useDAWStore.setState({ + tracks: [first, second], + selectedTrackId: "second", + selectedTrackIds: ["second"], + globalLocked: true, + }); + + const timelineDelete = getRegisteredAction("edit.delete")!; + const tcpDelete = getRegisteredAction("track.deleteSelected")!; + const duplicate = getRegisteredAction("track.duplicateSelected")!; + const group = getRegisteredAction("track.groupSelectedIntoFolder")!; + const moveUp = getRegisteredAction("track.moveSelectedUp")!; + for (const action of [timelineDelete, tcpDelete, duplicate, group, moveUp]) { + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + } + await useDAWStore.getState().deleteSelectedTracks(); + await useDAWStore.getState().duplicateSelectedTracks(); + expect(useDAWStore.getState().groupSelectedTracksIntoFolder()).toBe(false); + expect(useDAWStore.getState().moveSelectedTracks("up")).toBe(false); + + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["first", "second"]); + expect(removeTrack).not.toHaveBeenCalled(); + expect(addTrack).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(0); + + const muteAction = getRegisteredAction("track.toggleSelectedMute")!; + expect(muteAction.canHandleShortcut?.()).toBe(true); + muteAction.execute(); + expect(useDAWStore.getState().tracks[1].muted).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("inserts an empty audio item only on an eligible track with one stable undo", () => { + const audio = createDefaultTrack("audio", "Audio", "#111", "audio", []); + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", [audio]); + useDAWStore.setState({ + tracks: [audio, midi], + selectedTrackId: "midi", + selectedTrackIds: ["midi"], + transport: { ...useDAWStore.getState().transport, currentTime: -5 }, + }); + + const action = getRegisteredAction("insert.emptyItem")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + let state = useDAWStore.getState(); + expect(state.tracks[0].clips).toHaveLength(1); + expect(state.tracks[0].clips[0]).toMatchObject({ startTime: 0, duration: 4, filePath: "" }); + expect(state.tracks[1].clips).toHaveLength(0); + const clipId = state.tracks[0].clips[0].id; + expect(commandManager.getUndoStack()).toHaveLength(1); + + state.undo(); + expect(useDAWStore.getState().tracks[0].clips).toHaveLength(0); + state.redo(); + expect(useDAWStore.getState().tracks[0].clips[0].id).toBe(clipId); + + useDAWStore.getState().undo(); + commandManager.clear(); + useDAWStore.setState({ globalLocked: true, canUndo: false, canRedo: false }); + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + expect(useDAWStore.getState().tracks[0].clips).toHaveLength(0); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("inserts an empty MIDI clip only when item editing and its target track are available", () => { + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", []); + midi.frozen = true; + useDAWStore.setState({ + tracks: [midi], + selectedTrackId: midi.id, + selectedTrackIds: [midi.id], + transport: { ...useDAWStore.getState().transport, currentTime: 2 }, + }); + const action = getRegisteredAction("insert.emptyMidiClip")!; + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + expect(useDAWStore.getState().tracks[0].midiClips).toEqual([]); + + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => ({ ...track, frozen: false })), + lockSettings: { ...state.lockSettings, items: true }, + })); + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, items: false }, + })); + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + let state = useDAWStore.getState(); + expect(state.tracks[0].midiClips).toHaveLength(1); + expect(state.tracks[0].midiClips[0]).toMatchObject({ startTime: 2, duration: 4 }); + const clipId = state.tracks[0].midiClips[0].id; + expect(commandManager.getUndoStack()).toHaveLength(1); + state.undo(); + expect(useDAWStore.getState().tracks[0].midiClips).toEqual([]); + state.redo(); + expect(useDAWStore.getState().tracks[0].midiClips[0].id).toBe(clipId); + }); + + it("deletes a mixed clip selection before selected tracks in one undo step", () => { + const audio = createDefaultTrack("audio", "Audio", "#111", "audio", []); + audio.clips = [ + audioClip("audio-open"), + audioClip("audio-locked", { startTime: 2, locked: true }), + ]; + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", []); + midi.midiClips = [midiClip("midi-open")]; + const session = midiSession("midi-open"); + const deleteSelectedTracks = vi.fn(async () => undefined); + useDAWStore.setState({ + tracks: [audio, midi], + selectedTrackId: "audio", + selectedTrackIds: ["audio"], + selectedClipId: "midi-open", + selectedClipIds: ["audio-open", "audio-locked", "midi-open"], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + dockedMidiEditorSessionId: session.sessionId, + showPianoRoll: true, + pianoRollTrackId: "midi", + pianoRollClipId: "midi-open", + selectedNoteIds: ["note-a"], + deleteSelectedTracks, + }); + + getRegisteredAction("edit.delete")!.execute(); + let state = useDAWStore.getState(); + expect(state.tracks[0].clips.map((clip) => clip.id)).toEqual(["audio-locked"]); + expect(state.tracks[1].midiClips).toEqual([]); + expect(state.selectedClipIds).toEqual(["audio-locked"]); + expect(state.midiEditorSessions).toEqual([]); + expect(deleteSelectedTracks).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(1); + + state.undo(); + state = useDAWStore.getState(); + expect(state.tracks[0].clips.map((clip) => clip.id)).toEqual(["audio-open", "audio-locked"]); + expect(state.tracks[1].midiClips.map((clip) => clip.id)).toEqual(["midi-open"]); + expect(state.selectedClipIds).toEqual(["audio-open", "audio-locked", "midi-open"]); + expect(state.midiEditorSessions.map((entry) => entry.sessionId)).toEqual([session.sessionId]); + expect(commandManager.getUndoStack()).toHaveLength(0); + + state.redo(); + expect(useDAWStore.getState().tracks[1].midiClips).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("duplicates mixed audio/MIDI clips with stable ids as one command", () => { + const audio = createDefaultTrack("audio", "Audio", "#111", "audio", []); + audio.clips = [audioClip("audio-source", { startTime: 1, duration: 2 })]; + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", []); + midi.midiClips = [midiClip("midi-source", { startTime: 4, duration: 3 })]; + useDAWStore.setState({ + tracks: [audio, midi], + selectedClipId: "midi-source", + selectedClipIds: ["audio-source", "midi-source"], + }); + + getRegisteredAction("edit.duplicateClips")!.execute(); + let state = useDAWStore.getState(); + const duplicateIds = [...state.selectedClipIds]; + expect(duplicateIds).toHaveLength(2); + expect(state.tracks[0].clips[1]).toMatchObject({ id: duplicateIds[0], startTime: 3 }); + expect(state.tracks[1].midiClips[1]).toMatchObject({ id: duplicateIds[1], startTime: 7 }); + expect(commandManager.getUndoStack()).toHaveLength(1); + + state.undo(); + expect(useDAWStore.getState().selectedClipIds).toEqual(["audio-source", "midi-source"]); + expect(useDAWStore.getState().tracks[0].clips).toHaveLength(1); + state.redo(); + state = useDAWStore.getState(); + expect(state.selectedClipIds).toEqual(duplicateIds); + expect(state.tracks[0].clips[1].id).toBe(duplicateIds[0]); + expect(state.tracks[1].midiClips[1].id).toBe(duplicateIds[1]); + }); + + it("deep-clones nested audio takes/envelopes and MIDI source data", () => { + const audio = createDefaultTrack("audio", "Audio", "#111", "audio", []); + audio.clips = [audioClip("audio-source", { + gainEnvelope: [{ time: 0.25, gain: 0.75 }], + takes: [audioClip("nested-take", { + gainEnvelope: [{ time: 0.5, gain: 0.5 }], + })], + })]; + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", []); + midi.midiClips = [midiClip("midi-source", { + events: [{ timestamp: 0, type: "noteOn", note: 60, velocity: 100 }], + ccEvents: [{ cc: 1, time: 0, value: 64 }], + quantizeBackup: { + events: [{ timestamp: 0.1, type: "noteOn", note: 61, velocity: 90 }], + ccEvents: [{ cc: 11, time: 0.1, value: 80 }], + }, + })]; + useDAWStore.setState({ + tracks: [audio, midi], + selectedClipId: "midi-source", + selectedClipIds: ["audio-source", "midi-source"], + }); + + const duplicateIds = useDAWStore.getState().duplicateSelectedClips(); + const state = useDAWStore.getState(); + const sourceAudio = state.tracks[0].clips[0]; + const duplicateAudio = state.tracks[0].clips[1]; + const sourceMidi = state.tracks[1].midiClips[0]; + const duplicateMidi = state.tracks[1].midiClips[1]; + expect(duplicateAudio.gainEnvelope).not.toBe(sourceAudio.gainEnvelope); + expect(duplicateAudio.takes).not.toBe(sourceAudio.takes); + expect(duplicateAudio.takes?.[0]).not.toBe(sourceAudio.takes?.[0]); + expect(duplicateAudio.takes?.[0].id).not.toBe(sourceAudio.takes?.[0].id); + expect(duplicateMidi.events).not.toBe(sourceMidi.events); + expect(duplicateMidi.events[0]).not.toBe(sourceMidi.events[0]); + expect(duplicateMidi.ccEvents).not.toBe(sourceMidi.ccEvents); + expect(duplicateMidi.quantizeBackup).not.toBe(sourceMidi.quantizeBackup); + + duplicateAudio.gainEnvelope![0].gain = 1.5; + duplicateAudio.takes![0].gainEnvelope![0].gain = 1.25; + duplicateMidi.events[0].note = 72; + duplicateMidi.ccEvents![0].value = 127; + duplicateMidi.quantizeBackup!.events[0].note = 73; + expect(sourceAudio.gainEnvelope![0].gain).toBe(0.75); + expect(sourceAudio.takes![0].gainEnvelope![0].gain).toBe(0.5); + expect(sourceMidi.events[0].note).toBe(60); + expect(sourceMidi.ccEvents![0].value).toBe(64); + expect(sourceMidi.quantizeBackup!.events[0].note).toBe(61); + + state.undo(); + state.redo(); + expect(useDAWStore.getState().selectedClipIds).toEqual(duplicateIds); + }); + + it("closes deleted native MIDI windows and pitch ownership without phantom undo state", () => { + const audio = createDefaultTrack("audio", "Audio", "#111", "audio", []); + audio.clips = [audioClip("pitch-source")]; + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", []); + midi.midiClips = [midiClip("midi-source")]; + const session = { ...midiSession("midi-source"), mode: "windowed" as const }; + const closeMidiEditorWindow = vi.spyOn(nativeBridge, "closeMidiEditorWindow").mockResolvedValue(true); + const closePitchEditor = vi.fn(() => useDAWStore.setState({ + showPitchEditor: false, + pitchEditorTrackId: null, + pitchEditorClipId: null, + pitchEditorFxIndex: 0, + })); + useDAWStore.setState({ + tracks: [audio, midi], + selectedClipId: "midi-source", + selectedClipIds: ["pitch-source", "midi-source"], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + dockedMidiEditorSessionId: null, + detachedPanels: ["midiEditor"], + showPianoRoll: false, + pianoRollTrackId: "midi", + pianoRollClipId: "midi-source", + showPitchEditor: true, + pitchEditorTrackId: "audio", + pitchEditorClipId: "pitch-source", + closePitchEditor, + }); + + expect(useDAWStore.getState().deleteSelectedClips()).toBe(true); + expect(closeMidiEditorWindow).toHaveBeenCalledWith(session.sessionId, "sourceDelete"); + expect(closePitchEditor).toHaveBeenCalledTimes(1); + expect(useDAWStore.getState().midiEditorSessions).toEqual([]); + expect(useDAWStore.getState().showPitchEditor).toBe(false); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].id).toBe("pitch-source"); + expect(useDAWStore.getState().tracks[1].midiClips[0].id).toBe("midi-source"); + expect(useDAWStore.getState().midiEditorSessions).toEqual([]); + expect(useDAWStore.getState().detachedPanels).not.toContain("midiEditor"); + expect(useDAWStore.getState().showPitchEditor).toBe(false); + }); + + it("groups and ungroups mixed audio/MIDI items with shared selection semantics", () => { + const audio = createDefaultTrack("audio", "Audio", "#111", "audio", []); + audio.clips = [audioClip("audio-source")]; + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", []); + midi.midiClips = [midiClip("midi-source")]; + useDAWStore.setState({ + tracks: [audio, midi], + selectedClipId: "midi-source", + selectedClipIds: ["audio-source", "midi-source"], + }); + + expect(useDAWStore.getState().groupSelectedClips()).toBe(true); + const groupId = useDAWStore.getState().tracks[0].clips[0].groupId; + expect(groupId).toBeTruthy(); + expect(useDAWStore.getState().tracks[1].midiClips[0].groupId).toBe(groupId); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().selectClip("midi-source"); + expect(useDAWStore.getState().selectedClipIds).toEqual(["audio-source", "midi-source"]); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].groupId).toBeUndefined(); + expect(useDAWStore.getState().tracks[1].midiClips[0].groupId).toBeUndefined(); + + useDAWStore.getState().redo(); + commandManager.clear(); + expect(useDAWStore.getState().ungroupSelectedClips()).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].clips[0].groupId).toBeUndefined(); + expect(useDAWStore.getState().tracks[1].midiClips[0].groupId).toBeUndefined(); + }); + + it("quantizes unlocked audio and MIDI timeline items in one undo step", () => { + const audio = createDefaultTrack("audio", "Audio", "#111", "audio", []); + audio.clips = [ + audioClip("audio-source", { startTime: 0.13 }), + audioClip("locked", { startTime: 0.37, locked: true }), + ]; + audio.automationLanes = [{ + id: "volume-lane", + param: "volume", + points: [{ id: "point", time: 0.2, value: 0.5 }], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }]; + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", []); + midi.midiClips = [midiClip("midi-source", { startTime: 0.26 })]; + const syncAudio = vi.fn(async () => undefined); + const syncMIDI = vi.fn(async () => undefined); + useDAWStore.setState((current) => ({ + tracks: [audio, midi], + selectedClipId: "midi-source", + selectedClipIds: ["audio-source", "locked", "midi-source"], + transport: { ...current.transport, tempo: 120 }, + gridSize: "1/16", + quantizePresetId: "factory-1/16", + moveEnvelopesWithItems: true, + syncClipsWithBackend: syncAudio, + syncMIDITrackToBackend: syncMIDI, + })); + + expect(useDAWStore.getState().quantizeSelectedClips()).toBe(true); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.startTime)) + .toEqual([0.125, 0.37]); + expect(useDAWStore.getState().tracks[1].midiClips[0].startTime).toBe(0.25); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points[0].time).toBeCloseTo(0.195); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(syncAudio).toHaveBeenCalledTimes(1); + expect(syncMIDI).toHaveBeenCalledWith("midi", { debounce: false }); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(0.13); + expect(useDAWStore.getState().tracks[1].midiClips[0].startTime).toBe(0.26); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points[0].time).toBe(0.2); + }); + + it("toggles mute and lock per eligible item with one undo each", () => { + const track = createDefaultTrack("audio", "Audio", "#111", "audio", []); + track.clips = [ + audioClip("plain"), + audioClip("muted", { muted: true }), + audioClip("locked", { locked: true }), + ]; + useDAWStore.setState({ + tracks: [track], + selectedClipId: "locked", + selectedClipIds: ["plain", "muted", "locked"], + }); + + getRegisteredAction("edit.muteClips")!.execute(); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => [clip.id, !!clip.muted])) + .toEqual([["plain", true], ["muted", false], ["locked", false]]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => [clip.id, !!clip.muted])) + .toEqual([["plain", false], ["muted", true], ["locked", false]]); + + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + getRegisteredAction("edit.toggleClipLock")!.execute(); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => [clip.id, !!clip.locked])) + .toEqual([["plain", true], ["muted", true], ["locked", false]]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => [clip.id, !!clip.locked])) + .toEqual([["plain", false], ["muted", false], ["locked", true]]); + }); + + it("does not create history when every selected edit target is missing or locked", () => { + const track = createDefaultTrack("audio", "Audio", "#111", "audio", []); + track.clips = [audioClip("locked", { locked: true })]; + useDAWStore.setState({ + tracks: [track], + selectedClipId: "locked", + selectedClipIds: ["missing", "locked"], + }); + expect(useDAWStore.getState().deleteSelectedClips()).toBe(false); + expect(useDAWStore.getState().toggleSelectedClipsMuted()).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("enforces global, item, and per-clip locks in keyboard, wheel, and direct mutation paths", () => { + const track = createDefaultTrack("audio", "Audio", "#111", "audio", []); + track.clips = [audioClip("clip", { startTime: 1, volumeDB: 0, fadeIn: 0, fadeOut: 0 })]; + useDAWStore.setState({ + tracks: [track], + selectedClipId: "clip", + selectedClipIds: ["clip"], + }); + + const attemptAllEntrypoints = () => { + const state = useDAWStore.getState(); + state.setClipVolume("clip", 6); // Timeline wheel and volume-line endpoint. + state.beginClipFadeEdit("clip"); + state.previewClipFades("clip", 0.25, 0.25); + state.commitClipFadeEdit("clip"); + state.toggleClipMute("clip"); + state.nudgeClips("right", false); + state.setClipColor("clip", "#abcdef"); + getRegisteredAction("edit.muteClips")!.execute(); + }; + const expectUnchanged = () => { + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ + startTime: 1, + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + color: "#224466", + }); + expect(Boolean(useDAWStore.getState().tracks[0].clips[0].muted)).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + }; + + useDAWStore.setState({ globalLocked: true }); + attemptAllEntrypoints(); + expectUnchanged(); + + useDAWStore.setState({ + globalLocked: false, + lockSettings: { ...useDAWStore.getState().lockSettings, items: true }, + }); + attemptAllEntrypoints(); + expectUnchanged(); + + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, items: false }, + tracks: state.tracks.map((candidate) => ({ + ...candidate, + clips: candidate.clips.map((clip) => ({ ...clip, locked: true })), + })), + })); + attemptAllEntrypoints(); + expectUnchanged(); + }); +}); + +describe("selection semantics", () => { + it("clears every main-realm selection without creating undo history", () => { + const track = createDefaultTrack("track", "Track", "#111", "midi", []); + track.midiClips = [midiClip("clip")]; + const session = midiSession("clip"); + useDAWStore.setState({ + tracks: [track], + selectedTrackId: "track", + selectedTrackIds: ["track"], + lastSelectedTrackId: "track", + selectedClipId: "clip", + selectedClipIds: ["clip"], + selectedNoteIds: ["note-a"], + selectedAutomationTarget: { kind: "track", trackId: "track", laneId: "lane", pointId: "point" }, + selectedRegionIds: ["region"], + timeSelection: { start: 1, end: 2 }, + razorEdits: [{ trackId: "track", start: 1, end: 2 }], + midiEditRange: { startTime: 0, endTime: 1, minNote: 36, maxNote: 84, includeCC: true }, + pianoRollEditCursorTime: 0.5, + midiEditorSessions: [session], + }); + + getRegisteredAction("edit.deselectAll")!.execute(); + expect(useDAWStore.getState()).toMatchObject({ + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + selectedClipId: null, + selectedClipIds: [], + selectedNoteIds: [], + selectedAutomationTarget: null, + selectedRegionIds: [], + timeSelection: null, + razorEdits: [], + midiEditRange: null, + pianoRollEditCursorTime: null, + }); + expect(useDAWStore.getState().midiEditorSessions[0]).toMatchObject({ + selectedNoteIds: [], + midiEditRange: null, + editCursorTime: null, + }); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); + +describe("atomic selected-track shortcuts", () => { + it("inserts multiple native tracks with one stable undo command", async () => { + const addTrack = vi.spyOn(nativeBridge, "addTrack") + .mockImplementation(async (explicitId) => explicitId || "generated-track"); + const removeTrack = vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + const ids = await useDAWStore.getState().addTracksBatch([ + { id: "batch-a", name: "Audio 1", type: "audio" }, + { id: "batch-b", name: "MIDI 2", type: "midi" }, + ]); + expect(ids).toEqual(["batch-a", "batch-b"]); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(ids); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(addTrack).toHaveBeenCalledWith("batch-a", "audio"); + expect(addTrack).toHaveBeenCalledWith("batch-b", "midi"); + + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks).toEqual([])); + expect(removeTrack.mock.calls.map(([trackId]) => trackId)).toEqual(["batch-b", "batch-a"]); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(ids)); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("coalesces selected folder, automation, and spectral view toggles", () => { + const first = createDefaultTrack("first", "First", "#111", "audio", []); + const second = createDefaultTrack("second", "Second", "#222", "audio", [first]); + first.isFolder = true; + second.isFolder = true; + useDAWStore.setState({ + tracks: [first, second], + selectedTrackId: "second", + selectedTrackIds: ["first", "second"], + }); + + for (const actionId of [ + "track.toggleSelectedFolders", + "track.toggleSelectedAutomation", + "track.toggleSelectedSpectralView", + ]) { + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + getRegisteredAction(actionId)!.execute(); + expect(commandManager.getUndoStack(), actionId).toHaveLength(1); + useDAWStore.getState().undo(); + expect(commandManager.getUndoStack(), actionId).toHaveLength(0); + } + }); + + it("toggles linked and unlinked track booleans in one undo step", async () => { + const first = createDefaultTrack("first", "First", "#111", "audio", []); + const linked = createDefaultTrack("linked", "Linked", "#222", "audio", [first]); + const third = createDefaultTrack("third", "Third", "#333", "audio", [first, linked]); + third.muted = true; + third.soloed = true; + third.monitorEnabled = true; + third.phaseInverted = true; + useDAWStore.setState({ + tracks: [first, linked, third], + selectedTrackId: "first", + selectedTrackIds: ["first", "linked", "third"], + trackGroups: [{ + id: "linked-group", + name: "Linked", + leadTrackId: "first", + memberTrackIds: ["first", "linked"], + linkedParams: ["mute", "solo", "armed", "fxBypass"], + }], + }); + + getRegisteredAction("track.toggleSelectedMute")!.execute(); + expect(useDAWStore.getState().tracks.map((track) => track.muted)).toEqual([true, true, false]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((track) => track.muted)).toEqual([false, false, true]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.map((track) => track.muted)).toEqual([true, true, false]); + + const cases: Array<[string, keyof typeof first, boolean[]]> = [ + ["track.toggleSelectedSolo", "soloed", [true, true, false]], + ["track.toggleSelectedMonitor", "monitorEnabled", [true, true, false]], + ["track.toggleSelectedPhaseInvert", "phaseInverted", [true, true, false]], + ["track.toggleSelectedFxBypass", "fxBypassed", [true, true, true]], + ]; + for (const [actionId, field, expected] of cases) { + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + getRegisteredAction(actionId)!.execute(); + await vi.waitFor(() => { + expect(useDAWStore.getState().tracks.map((track) => Boolean(track[field])), actionId) + .toEqual(expected); + }); + expect(commandManager.getUndoStack(), actionId).toHaveLength(1); + useDAWStore.getState().undo(); + expect(commandManager.getUndoStack(), actionId).toHaveLength(0); + if (actionId === "track.toggleSelectedMonitor") { + await vi.waitFor(() => { + expect(useDAWStore.getState().tracks.map((track) => track.monitorEnabled)) + .toEqual([false, false, true]); + }); + } + } + }); + + it("arms eligible linked tracks atomically while leaving record-safe tracks untouched", () => { + const first = createDefaultTrack("first", "First", "#111", "audio", []); + const safe = createDefaultTrack("safe", "Safe", "#222", "audio", [first]); + safe.recordSafe = true; + useDAWStore.setState({ + tracks: [first, safe], + selectedTrackId: "first", + selectedTrackIds: ["first", "safe"], + trackGroups: [{ + id: "arm-group", + name: "Arm", + leadTrackId: "first", + memberTrackIds: ["first", "safe"], + linkedParams: ["armed"], + }], + }); + getRegisteredAction("track.toggleSelectedArm")!.execute(); + expect(useDAWStore.getState().tracks.map((track) => track.armed)).toEqual([true, false]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((track) => track.armed)).toEqual([false, false]); + }); + + it("duplicates a multi-track selection with stable ids in one command", async () => { + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + const first = createDefaultTrack("first", "First", "#111", "audio", []); + const second = createDefaultTrack("second", "Second", "#222", "midi", [first]); + useDAWStore.setState({ + tracks: [first, second], + selectedTrackId: "second", + selectedTrackIds: ["first", "second"], + }); + + const duplicateIds = await useDAWStore.getState().duplicateSelectedTracks(); + expect(duplicateIds).toHaveLength(2); + expect(useDAWStore.getState().tracks.map((track) => track.id)) + .toEqual(["first", duplicateIds[0], "second", duplicateIds[1]]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((track) => track.id)) + .toEqual(["first", "second"])); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["first", "second"]); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((track) => track.id)) + .toEqual(["first", duplicateIds[0], "second", duplicateIds[1]])); + expect(useDAWStore.getState().selectedTrackIds).toEqual(duplicateIds); + }); + + it("deletes a selected folder subtree and restores it as one native-safe command", async () => { + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + const addTrack = vi.spyOn(nativeBridge, "addTrack") + .mockImplementation(async (explicitId) => explicitId || "generated-track"); + const removeTrack = vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + const folder = createDefaultTrack("folder", "Folder", "#111", "audio", []); + folder.isFolder = true; + folder.clips = [audioClip("pitch-source")]; + const child = createDefaultTrack("child", "Child", "#222", "midi", [folder]); + child.parentFolderId = "folder"; + child.midiClips = [midiClip("child-clip")]; + const outside = createDefaultTrack("outside", "Outside", "#333", "audio", [folder, child]); + const session = { ...midiSession("child-clip"), trackId: "child", mode: "windowed" as const }; + const closeMidiEditorWindow = vi.spyOn(nativeBridge, "closeMidiEditorWindow").mockResolvedValue(true); + const closePitchEditor = vi.fn(() => useDAWStore.setState({ + showPitchEditor: false, + pitchEditorTrackId: null, + pitchEditorClipId: null, + pitchEditorFxIndex: 0, + })); + useDAWStore.setState({ + tracks: [folder, child, outside], + selectedTrackId: "folder", + selectedTrackIds: ["folder"], + selectedClipId: "child-clip", + selectedClipIds: ["child-clip"], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + detachedPanels: ["midiEditor"], + pianoRollTrackId: "child", + pianoRollClipId: "child-clip", + showPitchEditor: true, + pitchEditorTrackId: "folder", + pitchEditorClipId: "pitch-source", + closePitchEditor, + }); + + await useDAWStore.getState().deleteSelectedTracks(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["outside"]); + expect(useDAWStore.getState().selectedClipIds).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(removeTrack.mock.calls.map(([trackId]) => trackId)).toEqual(["child", "folder"]); + expect(closeMidiEditorWindow).toHaveBeenCalledWith(session.sessionId, "sourceTrackDelete"); + expect(closePitchEditor).toHaveBeenCalledTimes(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)) + .toEqual(["folder", "child", "outside"]); + expect(useDAWStore.getState().tracks[1].parentFolderId).toBe("folder"); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["folder"]); + expect(useDAWStore.getState().midiEditorSessions).toEqual([]); + expect(useDAWStore.getState().detachedPanels).not.toContain("midiEditor"); + expect(useDAWStore.getState().showPitchEditor).toBe(false); + await vi.waitFor(() => expect(addTrack).toHaveBeenCalledWith("folder", "audio")); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((track) => track.id)) + .toEqual(["outside"])); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("creates a bus with all selected-track sends as one rollback-safe command", async () => { + const first = createDefaultTrack("first", "First", "#111", "audio", []); + const second = createDefaultTrack("second", "Second", "#222", "midi", [first]); + const addTrack = vi.spyOn(nativeBridge, "addTrack") + .mockImplementation(async (explicitId) => explicitId || "generated-track"); + const removeTrack = vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + const addTrackSend = vi.spyOn(nativeBridge, "addTrackSend").mockResolvedValue(0); + const removeTrackSend = vi.spyOn(nativeBridge, "removeTrackSend").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [first, second], + selectedTrackId: "second", + selectedTrackIds: ["first", "second"], + }); + + const createBusAction = getRegisteredAction("insert.bus")!; + expect(createBusAction.canHandleShortcut?.()).toBe(true); + createBusAction.execute(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.some((track) => track.type === "bus")).toBe(true)); + let state = useDAWStore.getState(); + const bus = state.tracks.find((track) => track.type === "bus")!; + expect(bus).toBeDefined(); + expect(state.tracks.slice(0, 2).map((track) => track.sends.map((send) => send.destTrackId))) + .toEqual([[bus.id], [bus.id]]); + expect(addTrack).toHaveBeenCalledWith(bus.id, "bus"); + expect(addTrackSend.mock.calls).toEqual([["first", bus.id], ["second", bus.id]]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + state.undo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["first", "second"]); + expect(useDAWStore.getState().tracks.every((track) => track.sends.length === 0)).toBe(true); + await vi.waitFor(() => expect(removeTrack).toHaveBeenCalledWith(bus.id)); + expect(removeTrackSend.mock.calls).toEqual([["second", 0], ["first", 0]]); + + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.some((track) => track.id === bus.id)).toBe(true)); + expect(useDAWStore.getState().tracks.slice(0, 2).map((track) => track.sends.map((send) => send.destTrackId))) + .toEqual([[bus.id], [bus.id]]); + expect(addTrack.mock.calls.filter(([trackId]) => trackId === bus.id)).toHaveLength(2); + + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(removeTrack.mock.calls.filter(([trackId]) => trackId === bus.id)).toHaveLength(2)); + commandManager.clear(); + useDAWStore.setState({ + tracks: [first, second], + selectedTrackId: "second", + selectedTrackIds: ["first", "second"], + canUndo: false, + canRedo: false, + }); + addTrackSend.mockReset().mockResolvedValueOnce(0).mockResolvedValueOnce(-1); + removeTrackSend.mockClear(); + removeTrack.mockClear(); + + await useDAWStore.getState().createBusFromSelectedTracks(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["first", "second"]); + expect(useDAWStore.getState().tracks.every((track) => track.sends.length === 0)).toBe(true); + expect(removeTrackSend).toHaveBeenCalledWith("first", 0); + expect(removeTrack).toHaveBeenCalledTimes(1); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("clears every eligible selected sampler atomically and rolls a partial native failure back", async () => { + const first = createDefaultTrack("sampler-a", "Sampler A", "#111", "instrument", []); + first.samplerSamplePath = "C:/samples/a.wav"; + first.samplerRootNote = 48; + first.samplerSourceType = "audio"; + const second = createDefaultTrack("sampler-b", "Sampler B", "#222", "instrument", [first]); + second.samplerSamplePath = "C:/samples/b.sf2"; + second.samplerRootNote = 60; + second.samplerSourceType = "soundfont"; + const empty = createDefaultTrack("empty", "Empty", "#333", "instrument", [first, second]); + const clearSample = vi.spyOn(nativeBridge, "clearTrackSamplerSample").mockResolvedValue(true); + const restoreSample = vi.spyOn(nativeBridge, "setTrackSamplerSample").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [first, second, empty], + selectedTrackId: "empty", + selectedTrackIds: ["sampler-a", "sampler-b", "empty"], + }); + + expect(await useDAWStore.getState().clearSelectedTrackSamplerSamples()).toBe(true); + expect(useDAWStore.getState().tracks.map((track) => track.samplerSamplePath)) + .toEqual([undefined, undefined, undefined]); + expect(clearSample.mock.calls.map(([trackId]) => trackId)).toEqual(["sampler-a", "sampler-b"]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((track) => track.samplerSamplePath)) + .toEqual(["C:/samples/a.wav", "C:/samples/b.sf2", undefined])); + expect(restoreSample).toHaveBeenCalledWith("sampler-a", "C:/samples/a.wav", 48); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks[0].samplerSamplePath).toBeUndefined()); + + commandManager.clear(); + useDAWStore.setState({ + tracks: [first, second], + selectedTrackId: "sampler-b", + selectedTrackIds: ["sampler-a", "sampler-b"], + canUndo: false, + canRedo: false, + }); + clearSample.mockReset() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + restoreSample.mockClear(); + expect(await useDAWStore.getState().clearSelectedTrackSamplerSamples()).toBe(false); + expect(useDAWStore.getState().tracks.map((track) => track.samplerSamplePath)) + .toEqual(["C:/samples/a.wav", "C:/samples/b.sf2"]); + expect(restoreSample).toHaveBeenCalledWith("sampler-a", "C:/samples/a.wav", 48); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("removes plugin and built-in instruments as one reversible selected-track command", async () => { + const plugin = createDefaultTrack("plugin", "Plugin", "#111", "instrument", []); + plugin.instrumentPlugin = "C:/plugins/synth.vst3"; + const builtIn = createDefaultTrack("builtin", "Built-in", "#222", "instrument", [plugin]); + builtIn.builtInInstrument = "piano"; + const plain = createDefaultTrack("plain", "Plain", "#333", "midi", [plugin, builtIn]); + vi.spyOn(nativeBridge, "getInstrumentState").mockResolvedValue("saved-state"); + const removeInstrument = vi.spyOn(nativeBridge, "removeInstrument").mockResolvedValue(true); + const setTrackType = vi.spyOn(nativeBridge, "setTrackType").mockResolvedValue(true); + const loadInstrument = vi.spyOn(nativeBridge, "loadInstrument").mockResolvedValue(true); + const setInstrumentState = vi.spyOn(nativeBridge, "setInstrumentState").mockResolvedValue(true); + const setBuiltIn = vi.spyOn(nativeBridge, "setBuiltInPluginParam").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [plugin, builtIn, plain], + selectedTrackId: "plain", + selectedTrackIds: ["plugin", "builtin", "plain"], + }); + + expect(await useDAWStore.getState().removeSelectedTrackInstruments()).toBe(true); + expect(useDAWStore.getState().tracks.map((track) => [track.type, track.instrumentPlugin, track.builtInInstrument])) + .toEqual([ + ["midi", undefined, undefined], + ["midi", undefined, undefined], + ["midi", undefined, undefined], + ]); + expect(removeInstrument).toHaveBeenCalledWith("plugin"); + expect(setTrackType).toHaveBeenCalledWith("builtin", "midi"); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks[0].instrumentPlugin) + .toBe("C:/plugins/synth.vst3")); + expect(useDAWStore.getState().tracks[1].builtInInstrument).toBe("piano"); + expect(loadInstrument).toHaveBeenCalledWith("plugin", "C:/plugins/synth.vst3"); + expect(setInstrumentState).toHaveBeenCalledWith("plugin", "saved-state"); + expect(setBuiltIn).toHaveBeenCalledWith( + { trackId: "builtin", chain: "instrument", fxIndex: -1 }, + "instrumentMode", + 1, + ); + }); + + it("rolls back selected instrument removal after a partial native failure", async () => { + const first = createDefaultTrack("plugin-a", "Plugin A", "#111", "instrument", []); + first.instrumentPlugin = "C:/plugins/a.vst3"; + const second = createDefaultTrack("plugin-b", "Plugin B", "#222", "instrument", [first]); + second.instrumentPlugin = "C:/plugins/b.vst3"; + vi.spyOn(nativeBridge, "getInstrumentState").mockResolvedValue("state"); + vi.spyOn(nativeBridge, "removeInstrument") + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const loadInstrument = vi.spyOn(nativeBridge, "loadInstrument").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setTrackType").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setInstrumentState").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [first, second], + selectedTrackId: "plugin-b", + selectedTrackIds: ["plugin-a", "plugin-b"], + }); + + expect(await useDAWStore.getState().removeSelectedTrackInstruments()).toBe(false); + expect(useDAWStore.getState().tracks.map((track) => track.instrumentPlugin)) + .toEqual(["C:/plugins/a.vst3", "C:/plugins/b.vst3"]); + expect(loadInstrument).toHaveBeenCalledWith("plugin-a", "C:/plugins/a.vst3"); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("freezes audio and MIDI selections with one stable command and safe editor cleanup", async () => { + const audio = createDefaultTrack("audio", "Audio", "#111", "audio", []); + audio.clips = [audioClip("pitch-source", { startTime: 1, duration: 2 })]; + audio.trackFxCount = 1; + const midi = createDefaultTrack("midi", "MIDI", "#222", "instrument", [audio]); + midi.midiClips = [midiClip("midi-source", { startTime: 2, duration: 3 })]; + const empty = createDefaultTrack("empty", "Empty", "#333", "audio", [audio, midi]); + const docked = { ...midiSession("midi-source"), sessionId: "docked", mode: "docked" as const }; + const windowed = { ...midiSession("midi-source"), sessionId: "windowed", mode: "windowed" as const }; + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockImplementation(async (trackId) => + trackId === "audio" ? [{ bypassed: false }] : []); + const freezeTrack = vi.spyOn(nativeBridge, "freezeTrack").mockImplementation(async (trackId) => ({ + success: true, + filePath: `C:/freeze/${trackId}.wav`, + startTime: trackId === "audio" ? 1 : 2, + duration: trackId === "audio" ? 2 : 3, + sampleRate: 48000, + })); + const unfreezeTrack = vi.spyOn(nativeBridge, "unfreezeTrack").mockResolvedValue(true); + const bypassTrackFX = vi.spyOn(nativeBridge, "bypassTrackFX").mockResolvedValue(true); + const closeMidiEditorWindow = vi.spyOn(nativeBridge, "closeMidiEditorWindow").mockResolvedValue(true); + const closePitchEditor = vi.fn(() => useDAWStore.setState({ + showPitchEditor: false, + pitchEditorTrackId: null, + pitchEditorClipId: null, + pitchEditorFxIndex: 0, + })); + useDAWStore.setState({ + tracks: [audio, midi, empty], + selectedTrackId: "empty", + selectedTrackIds: ["audio", "midi", "empty"], + midiEditorSessions: [docked, windowed], + activeMidiEditorSessionId: windowed.sessionId, + dockedMidiEditorSessionId: docked.sessionId, + detachedPanels: ["midiEditor"], + showPianoRoll: true, + pianoRollTrackId: "midi", + pianoRollClipId: "midi-source", + showPitchEditor: true, + pitchEditorTrackId: "audio", + pitchEditorClipId: "pitch-source", + closePitchEditor, + }); + + expect(await useDAWStore.getState().toggleSelectedTracksFreeze()).toBe(true); + let state = useDAWStore.getState(); + expect(state.tracks.map((track) => track.frozen)).toEqual([true, true, false]); + expect(state.tracks[0].clips[0]).toMatchObject({ id: "audio_freeze", filePath: "C:/freeze/audio.wav" }); + expect(state.tracks[1].clips[0]).toMatchObject({ id: "midi_freeze", filePath: "C:/freeze/midi.wav" }); + expect(state.tracks[1].midiClips).toEqual([]); + expect(state.tracks[1].frozenOriginalMIDIClips?.map((clip) => clip.id)).toEqual(["midi-source"]); + expect(state.midiEditorSessions).toEqual([]); + expect(closeMidiEditorWindow).toHaveBeenCalledWith("windowed", "sourceFreeze"); + expect(closePitchEditor).toHaveBeenCalledTimes(1); + expect(bypassTrackFX).toHaveBeenCalledWith("audio", 0, true); + expect(commandManager.getUndoStack()).toHaveLength(1); + + state.undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks[0].frozen).toBe(false)); + state = useDAWStore.getState(); + expect(state.tracks[0].clips.map((clip) => clip.id)).toEqual(["pitch-source"]); + expect(state.tracks[1].midiClips.map((clip) => clip.id)).toEqual(["midi-source"]); + expect(state.midiEditorSessions.map((session) => session.sessionId)).toEqual(["docked"]); + expect(state.midiEditorSessions.some((session) => session.mode === "windowed")).toBe(false); + expect(state.showPitchEditor).toBe(false); + expect(unfreezeTrack).toHaveBeenCalledWith("audio"); + expect(bypassTrackFX).toHaveBeenCalledWith("audio", 0, false); + + state.redo(); + await vi.waitFor(() => expect( + freezeTrack.mock.calls.filter(([trackId]) => trackId === "audio"), + ).toHaveLength(2)); + expect(useDAWStore.getState().tracks[0].frozen).toBe(true); + expect(useDAWStore.getState().tracks[0].clips[0].id).toBe("audio_freeze"); + }); + + it("captures freeze targets at invocation and leaves an empty selection as a true no-op", async () => { + const first = createDefaultTrack("first", "First", "#111", "audio", []); + first.clips = [audioClip("first-clip")]; + const second = createDefaultTrack("second", "Second", "#222", "audio", [first]); + second.clips = [audioClip("second-clip")]; + let resolveFreeze: ((value: { success: boolean; filePath: string; duration: number; sampleRate: number }) => void) | null = null; + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "freezeTrack").mockImplementation(() => new Promise((resolve) => { + resolveFreeze = resolve; + })); + vi.spyOn(nativeBridge, "unfreezeTrack").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [first, second], + selectedTrackId: "first", + selectedTrackIds: ["first"], + }); + + const pending = useDAWStore.getState().toggleSelectedTracksFreeze(); + await vi.waitFor(() => expect(resolveFreeze).not.toBeNull()); + useDAWStore.setState({ selectedTrackId: "second", selectedTrackIds: ["second"] }); + resolveFreeze!({ success: true, filePath: "C:/freeze/first.wav", duration: 1, sampleRate: 48000 }); + expect(await pending).toBe(true); + expect(useDAWStore.getState().tracks.map((track) => track.frozen)).toEqual([true, false]); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["second"]); + + commandManager.clear(); + useDAWStore.setState({ + tracks: [createDefaultTrack("empty", "Empty", "#333", "audio", [])], + selectedTrackId: "empty", + selectedTrackIds: ["empty"], + canUndo: false, + canRedo: false, + }); + expect(await useDAWStore.getState().toggleSelectedTracksFreeze()).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("keeps project state/history unchanged when a multi-track freeze render partially fails", async () => { + const first = createDefaultTrack("first", "First", "#111", "audio", []); + first.clips = [audioClip("first-clip")]; + const second = createDefaultTrack("second", "Second", "#222", "midi", [first]); + second.midiClips = [midiClip("second-clip")]; + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "freezeTrack") + .mockResolvedValueOnce({ success: true, filePath: "C:/freeze/first.wav", duration: 1, sampleRate: 48000 }) + .mockResolvedValueOnce({ success: false, error: "render failed" }); + useDAWStore.setState({ + tracks: [first, second], + selectedTrackId: "second", + selectedTrackIds: ["first", "second"], + }); + + expect(await useDAWStore.getState().toggleSelectedTracksFreeze()).toBe(false); + expect(useDAWStore.getState().tracks.map((track) => track.frozen)).toEqual([false, false]); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual(["first-clip"]); + expect(useDAWStore.getState().tracks[1].midiClips.map((clip) => clip.id)).toEqual(["second-clip"]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); + +describe("atomic multi-item catalog actions", () => { + it("repeats and recolors mixed selected clips as one undo gesture", () => { + const audio = createDefaultTrack("audio", "Audio", "#111", "audio", []); + audio.clips = [audioClip("audio-source")]; + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", []); + midi.midiClips = [midiClip("midi-source")]; + useDAWStore.setState({ + tracks: [audio, midi], + selectedClipId: "midi-source", + selectedClipIds: ["audio-source", "midi-source"], + }); + vi.stubGlobal("prompt", vi.fn(() => "2")); + + getRegisteredAction("clip.repeatSelected")!.execute(); + expect(useDAWStore.getState().tracks[0].clips).toHaveLength(3); + expect(useDAWStore.getState().tracks[1].midiClips).toHaveLength(3); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips).toHaveLength(1); + expect(useDAWStore.getState().tracks[1].midiClips).toHaveLength(1); + expect(useDAWStore.getState().selectedClipIds).toEqual(["audio-source", "midi-source"]); + + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + vi.stubGlobal("prompt", vi.fn(() => "#abcdef")); + getRegisteredAction("clip.setSelectedColor")!.execute(); + expect(useDAWStore.getState().tracks[0].clips[0].color).toBe("#abcdef"); + expect(useDAWStore.getState().tracks[1].midiClips[0].color).toBe("#abcdef"); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].color).toBe("#224466"); + expect(useDAWStore.getState().tracks[1].midiClips[0].color).toBe("#664422"); + }); + + it("batches MIDI source-window and selected-note transforms across clips", () => { + const midi = createDefaultTrack("midi", "MIDI", "#222", "midi", []); + const noteEvents = (note: number) => [ + { timestamp: 0, type: "noteOn" as const, note, velocity: 100 }, + { timestamp: 0.5, type: "noteOff" as const, note, velocity: 0 }, + ]; + midi.midiClips = [ + midiClip("first-midi", { offset: 0.25, loopOffset: 0.25, events: noteEvents(60) }), + midiClip("second-midi", { offset: 0.5, loopOffset: 0.5, events: noteEvents(64) }), + ]; + useDAWStore.setState({ + tracks: [midi], + selectedClipId: "second-midi", + selectedClipIds: ["first-midi", "second-midi"], + }); + + getRegisteredAction("clip.resetSelectedMidiSourceOffset")!.execute(); + expect(useDAWStore.getState().tracks[0].midiClips.map((clip) => clip.offset)).toEqual([0, 0]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].midiClips.map((clip) => clip.offset)).toEqual([0.25, 0.5]); + + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + getRegisteredAction("clip.transposeSelectedMidiUp")!.execute(); + expect(useDAWStore.getState().tracks[0].midiClips.map((clip) => clip.events[0].note)) + .toEqual([61, 65]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].midiClips.map((clip) => clip.events[0].note)) + .toEqual([60, 64]); + }); +}); diff --git a/frontend/src/__tests__/audioBufferOptions.test.ts b/frontend/src/__tests__/audioBufferOptions.test.ts new file mode 100644 index 0000000..8aa9392 --- /dev/null +++ b/frontend/src/__tests__/audioBufferOptions.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + resolveAudioBufferSizeOptions, + resolveAudioBufferSizeRequest, +} from "../utils/audioBufferOptions"; + +describe("audio-buffer options", () => { + it("keeps every driver-reported size, including 8 samples", () => { + expect(resolveAudioBufferSizeOptions([ + 8, + 16, + 32, + 64, + 128, + 256, + 512, + ])).toEqual([8, 16, 32, 64, 128, 256, 512]); + }); + + it("normalizes duplicate and invalid capability values only", () => { + expect(resolveAudioBufferSizeOptions([ + 256, + 8, + 64, + 8, + 0, + Number.NaN, + ])).toEqual([8, 64, 256]); + }); + + it("includes the active size when the driver capability list omits it", () => { + expect(resolveAudioBufferSizeOptions([64, 128, 256], 32)) + .toEqual([32, 64, 128, 256]); + }); + + it("preserves a supported low-latency user choice without coercion", () => { + expect(resolveAudioBufferSizeRequest(8, [8, 16, 32, 64])) + .toBe(8); + }); + + it("uses a fallback only when the requested and reported data are absent", () => { + expect(resolveAudioBufferSizeOptions([])).toEqual([512]); + expect(resolveAudioBufferSizeRequest(undefined, [64, 128])) + .toBe(64); + }); +}); diff --git a/frontend/src/__tests__/audioDeadlineStatus.test.ts b/frontend/src/__tests__/audioDeadlineStatus.test.ts new file mode 100644 index 0000000..ceb590c --- /dev/null +++ b/frontend/src/__tests__/audioDeadlineStatus.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { resolveAudioDeadlineStatus } from "../utils/audioDeadlineStatus"; + +const base = { + blockSize: 480, + sampleRate: 48000, + lastCallbackCounter: 1000, + lastAudioCallbackDeadlineMissWhileRecording: false, +}; + +describe("audio deadline warning state", () => { + it("stays quiet without deadline misses", () => { + expect(resolveAudioDeadlineStatus(base)).toMatchObject({ + recent: false, + recording: false, + shouldWarn: false, + }); + }); + + it("keeps one recent idle miss in diagnostics without raising the banner", () => { + expect(resolveAudioDeadlineStatus({ + ...base, + audioCallbackDeadlineMissCount: 1, + audioCallbackDeadlineMissBurstCount: 1, + lastAudioCallbackDeadlineMissCounter: 999, + lastAudioCallbackDeadlineMissProcessMs: 10.4, + })).toMatchObject({ + recent: true, + burstMissCount: 1, + lastMissProcessMs: 10.4, + shouldWarn: false, + }); + }); + + it("warns for a recent repeated idle burst", () => { + expect(resolveAudioDeadlineStatus({ + ...base, + audioCallbackDeadlineMissCount: 3, + audioCallbackDeadlineMissBurstCount: 3, + lastAudioCallbackDeadlineMissCounter: 995, + })).toMatchObject({ + recent: true, + burstMissCount: 3, + shouldWarn: true, + }); + }); + + it("warns after one recent miss while actively recording", () => { + expect(resolveAudioDeadlineStatus({ + ...base, + lastAudioCallbackDeadlineMissWhileRecording: true, + audioCallbackDeadlineMissCount: 1, + audioCallbackDeadlineMissBurstCount: 1, + lastAudioCallbackDeadlineMissCounter: 999, + })).toMatchObject({ + recent: true, + recording: true, + shouldWarn: true, + }); + }); + + it("automatically clears an old burst while retaining its session count", () => { + const status = resolveAudioDeadlineStatus({ + ...base, + lastCallbackCounter: 2000, + audioCallbackDeadlineMissCount: 4, + audioCallbackDeadlineMissBurstCount: 4, + lastAudioCallbackDeadlineMissCounter: 1, + }); + + expect(status.deviceSessionMissCount).toBe(4); + expect(status.recent).toBe(false); + expect(status.shouldWarn).toBe(false); + }); + + it("fails quiet for incomplete or reset telemetry", () => { + expect(resolveAudioDeadlineStatus({ + ...base, + audioCallbackDeadlineMissCount: 2, + audioCallbackDeadlineMissBurstCount: 2, + lastAudioCallbackDeadlineMissCounter: 1001, + }).shouldWarn).toBe(false); + expect(resolveAudioDeadlineStatus({ + audioCallbackDeadlineMissCount: 2, + audioCallbackDeadlineMissBurstCount: 2, + }).shouldWarn).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/automationActionRegistryRouting.test.ts b/frontend/src/__tests__/automationActionRegistryRouting.test.ts new file mode 100644 index 0000000..3d0ad4d --- /dev/null +++ b/frontend/src/__tests__/automationActionRegistryRouting.test.ts @@ -0,0 +1,466 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { + getActionShortcutScopes, + getRegisteredAction, + isRemoteAutomationActionId, + routeAutomationAction, +} from "../store/actionRegistry"; +import { executeDetachedMainActionRequest } from "../utils/detachedMainActionRouting"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AutomationLane, + type Track, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +const stableAutomationActionIds = [ + "automation.toggleArrangementView", + "automation.writeBehavior.touch", + "automation.writeBehavior.latch", + "automation.writeBehavior.overwrite", + ...(["off", "read", "write", "touch", "latch"] as const).flatMap((mode) => [ + `automation.selectedTracks.mode.${mode}`, + `automation.master.mode.${mode}`, + `automation.selectedLane.mode.${mode}`, + ]), + "automation.selectedTracks.toggleOffRead", + "automation.selectedTracks.toggleLatchRead", + "automation.allTracks.mode.off", + "automation.allTracks.mode.read", + "automation.allTracks.mode.touch", + "automation.allTracks.mode.latch", + "automation.allTracks.writeOff", + "automation.allTracks.toggleRead", + ...(["show", "hide", "readOn", "readOff", "writeOn", "writeOff"] as const).flatMap((operation) => [ + `automation.selectedTracks.${operation}`, + `automation.master.${operation}`, + `automation.selectedLane.${operation}`, + ]), + "automation.point.selectNext", + "automation.point.selectPrevious", + "automation.point.deleteSelected", + "automation.point.nudgeTimeLeft", + "automation.point.nudgeTimeRight", + "automation.point.nudgeValueUp", + "automation.point.nudgeValueDown", + "automation.point.addAtPlayhead", + "automation.selectedLane.clear", + "automation.suspend", + "automation.resume", +] as const; + +function volumeLane(): AutomationLane { + return { + id: "volume-lane", + param: "volume", + points: [{ id: "point-a", time: 1, value: 0.5 }], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }; +} + +function automationTrack(id: string): Track { + return { + ...createDefaultTrack(id, id, "#14b8a6", "audio", []), + automationReadEnabled: true, + automationWriteEnabled: false, + automationEnabled: true, + automationLanes: [volumeLane()], + }; +} + +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState({ + ...originalState, + tracks: [automationTrack("track-a"), automationTrack("track-b")], + selectedTrackId: "track-a", + selectedTrackIds: ["track-a", "track-b"], + selectedClipId: null, + selectedClipIds: [], + timeSelection: null, + selectedAutomationTarget: { + kind: "track", + trackId: "track-a", + laneId: "volume-lane", + pointId: "point-a", + }, + canUndo: false, + canRedo: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("automation action registry", () => { + it("registers every stable action ID but forwards only authoritatively targetable automation", () => { + for (const actionId of stableAutomationActionIds) { + expect(getRegisteredAction(actionId), actionId).toBeDefined(); + const targetNeedsExactLaneOrPoint = actionId.startsWith("automation.selectedLane.") + || actionId.startsWith("automation.point."); + expect(isRemoteAutomationActionId(actionId), actionId).toBe(!targetNeedsExactLaneOrPoint); + } + + expect(isRemoteAutomationActionId("edit.delete")).toBe(false); + expect(isRemoteAutomationActionId("automation.not-a-real-action")).toBe(false); + }); + + it("keeps adjacent-lane selection local to the focused automation window", () => { + for (const actionId of [ + "automation.lane.selectPrevious", + "automation.lane.selectNext", + ]) { + const action = getRegisteredAction(actionId); + expect(action).toMatchObject({ shortcutScope: "automation" }); + expect(action?.shortcut).toBeUndefined(); + expect(isRemoteAutomationActionId(actionId)).toBe(false); + } + + const selectAdjacentAutomationLane = vi.fn(); + useDAWStore.setState({ selectAdjacentAutomationLane }); + getRegisteredAction("automation.lane.selectPrevious")?.execute(); + getRegisteredAction("automation.lane.selectNext")?.execute(); + expect(selectAdjacentAutomationLane.mock.calls).toEqual([["previous"], ["next"]]); + }); + + it("lets adjacent-lane actions recover a stale selection instead of consuming a no-op", () => { + useDAWStore.setState({ + selectedAutomationTarget: { + kind: "track", + trackId: "missing-track", + laneId: "missing-lane", + pointId: null, + }, + }); + const nextLane = getRegisteredAction("automation.lane.selectNext")!; + expect(nextLane.canHandleShortcut?.()).toBe(true); + + nextLane.execute(); + + expect(useDAWStore.getState().selectedAutomationTarget).toEqual({ + kind: "track", + trackId: "track-a", + laneId: "volume-lane", + pointId: null, + }); + }); + + it("keeps arrangement A surface-scoped and declares the point-editing defaults", () => { + const arrangement = getRegisteredAction("automation.toggleArrangementView")!; + expect(arrangement).toMatchObject({ shortcut: "A", shortcutScope: "timeline" }); + expect(getActionShortcutScopes(arrangement)).toEqual(["timeline", "automation"]); + expect(getActionShortcutScopes(arrangement)).not.toContain("global"); + + expect(getRegisteredAction("automation.point.selectNext")).toMatchObject({ shortcut: "Tab", shortcutScope: "automation" }); + expect(getRegisteredAction("automation.point.selectPrevious")).toMatchObject({ shortcut: "Shift+Tab", shortcutScope: "automation" }); + expect(getRegisteredAction("automation.point.deleteSelected")).toMatchObject({ + shortcut: "Delete", + shortcutAliases: ["Backspace"], + shortcutScope: "automation", + }); + expect(getRegisteredAction("automation.point.nudgeTimeLeft")?.shortcut).toBe("Left"); + expect(getRegisteredAction("automation.point.nudgeTimeRight")?.shortcut).toBe("Right"); + expect(getRegisteredAction("automation.point.nudgeValueUp")?.shortcut).toBe("Up"); + expect(getRegisteredAction("automation.point.nudgeValueDown")?.shortcut).toBe("Down"); + expect(getRegisteredAction("automation.point.addAtPlayhead")?.shortcut).toBe("Ctrl+Enter"); + expect(getRegisteredAction("automation.selectedLane.clear")?.shortcut).toBe("Ctrl+Delete"); + }); + + it("routes selected-track modes through one atomic store command", () => { + getRegisteredAction("automation.selectedTracks.mode.write")?.execute(); + + const state = useDAWStore.getState(); + expect(state.tracks.map((track) => track.automationWriteEnabled)).toEqual([true, true]); + expect(state.tracks.flatMap((track) => track.automationLanes.map((lane) => lane.mode))) + .toEqual(["write", "write"]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + state.undo(); + expect(useDAWStore.getState().tracks.map((track) => track.automationWriteEnabled)) + .toEqual([false, false]); + }); + + it("routes Logic/Cakewalk selected and all-track actions through one batch API call", () => { + const toggleTracksAutomationModes = vi.fn(); + const setTracksAutomationMode = vi.fn(); + const setTracksAutomationWrite = vi.fn(); + const toggleTracksAutomationRead = vi.fn(); + useDAWStore.setState({ + toggleTracksAutomationModes, + setTracksAutomationMode, + setTracksAutomationWrite, + toggleTracksAutomationRead, + }); + + getRegisteredAction("automation.selectedTracks.toggleOffRead")?.execute(); + getRegisteredAction("automation.selectedTracks.toggleLatchRead")?.execute(); + for (const mode of ["off", "read", "touch", "latch"] as const) { + getRegisteredAction(`automation.allTracks.mode.${mode}`)?.execute(); + } + getRegisteredAction("automation.allTracks.writeOff")?.execute(); + getRegisteredAction("automation.allTracks.toggleRead")?.execute(); + + expect(toggleTracksAutomationModes.mock.calls).toEqual([ + [["track-a", "track-b"], "off", "read"], + [["track-a", "track-b"], "latch", "read"], + ]); + expect(setTracksAutomationMode.mock.calls).toEqual([ + [["track-a", "track-b"], "off"], + [["track-a", "track-b"], "read"], + [["track-a", "track-b"], "touch"], + [["track-a", "track-b"], "latch"], + ]); + expect(setTracksAutomationWrite).toHaveBeenCalledOnce(); + expect(setTracksAutomationWrite).toHaveBeenCalledWith(["track-a", "track-b"], false); + expect(toggleTracksAutomationRead).toHaveBeenCalledOnce(); + expect(toggleTracksAutomationRead).toHaveBeenCalledWith(["track-a", "track-b"]); + }); + + it("keeps repeated all-track mode and one-way write commands no-op safe", () => { + getRegisteredAction("automation.allTracks.mode.touch")?.execute(); + expect(useDAWStore.getState().tracks.map((track) => track.automationWriteEnabled)) + .toEqual([true, true]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + getRegisteredAction("automation.allTracks.mode.touch")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(1); + + commandManager.clear(); + getRegisteredAction("automation.allTracks.writeOff")?.execute(); + expect(useDAWStore.getState().tracks.map((track) => track.automationWriteEnabled)) + .toEqual([false, false]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + getRegisteredAction("automation.allTracks.writeOff")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("commits each selected-track two-mode toggle as one undo command", () => { + getRegisteredAction("automation.selectedTracks.toggleOffRead")?.execute(); + expect(useDAWStore.getState().tracks.flatMap((track) => track.automationLanes.map((lane) => lane.mode))) + .toEqual(["off", "off"]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + getRegisteredAction("automation.selectedTracks.toggleOffRead")?.execute(); + expect(useDAWStore.getState().tracks.flatMap((track) => track.automationLanes.map((lane) => lane.mode))) + .toEqual(["read", "read"]); + expect(commandManager.getUndoStack()).toHaveLength(2); + + commandManager.clear(); + getRegisteredAction("automation.selectedTracks.toggleLatchRead")?.execute(); + expect(useDAWStore.getState().tracks.flatMap((track) => track.automationLanes.map((lane) => lane.mode))) + .toEqual(["latch", "latch"]); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("shows and hides every selected track with one idempotent history entry", () => { + getRegisteredAction("automation.selectedTracks.show")?.execute(); + expect(useDAWStore.getState().tracks.map((track) => track.showAutomation)) + .toEqual([true, true]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + getRegisteredAction("automation.selectedTracks.show")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(1); + + getRegisteredAction("automation.selectedTracks.hide")?.execute(); + expect(useDAWStore.getState().tracks.map((track) => track.showAutomation)) + .toEqual([false, false]); + expect(commandManager.getUndoStack()).toHaveLength(2); + + getRegisteredAction("automation.selectedTracks.hide")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(2); + }); + + it("keeps master one-way show/hide/read/write actions idempotent", () => { + useDAWStore.setState({ + showMasterAutomation: false, + masterAutomationLanes: [{ ...volumeLane(), visible: false }], + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: true, + }); + + getRegisteredAction("automation.master.show")?.execute(); + expect(useDAWStore.getState().showMasterAutomation).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(1); + getRegisteredAction("automation.master.show")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(1); + + getRegisteredAction("automation.master.hide")?.execute(); + expect(useDAWStore.getState().showMasterAutomation).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(2); + getRegisteredAction("automation.master.hide")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(2); + + commandManager.clear(); + getRegisteredAction("automation.master.readOn")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(0); + getRegisteredAction("automation.master.readOff")?.execute(); + expect(useDAWStore.getState().masterAutomationReadEnabled).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(1); + getRegisteredAction("automation.master.readOff")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(1); + + commandManager.clear(); + getRegisteredAction("automation.master.writeOff")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(0); + getRegisteredAction("automation.master.writeOn")?.execute(); + expect(useDAWStore.getState().masterAutomationWriteEnabled).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(1); + getRegisteredAction("automation.master.writeOn")?.execute(); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("does not claim point-edit shortcuts for a stale or locked selection", () => { + const remove = getRegisteredAction("automation.point.deleteSelected")!; + expect(remove.canHandleShortcut?.()).toBe(true); + + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, envelopes: true }, + })); + expect(remove.canHandleShortcut?.()).toBe(false); + + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, envelopes: false }, + selectedAutomationTarget: null, + })); + expect(remove.canHandleShortcut?.()).toBe(false); + }); + + it.each(["global", "envelope"] as const)( + "does not claim automation mode/read/write commands under %s lock", + (lockKind) => { + useDAWStore.setState((state) => ({ + globalLocked: lockKind === "global", + lockSettings: { + ...state.lockSettings, + envelopes: lockKind === "envelope", + }, + })); + + const nonMutatingStableIds = new Set([ + "automation.toggleArrangementView", + "automation.point.selectNext", + "automation.point.selectPrevious", + ]); + for (const actionId of [ + ...stableAutomationActionIds.filter((id) => !nonMutatingStableIds.has(id)), + "track.toggleSelectedAutomationRead", + "track.toggleSelectedAutomationWrite", + "track.toggleSelectedAutomation", + "automation.showAllSelectedTrackEnvelopes", + "automation.hideAllSelectedTrackEnvelopes", + "mixer.toggleMasterAutomationRead", + "mixer.toggleMasterAutomationWrite", + "mixer.toggleMasterAutomationLanes", + ]) { + expect(getRegisteredAction(actionId)?.canHandleShortcut?.(), actionId).toBe(false); + } + + expect(getRegisteredAction("automation.toggleArrangementView")?.canHandleShortcut?.()).toBe(true); + expect(getRegisteredAction("automation.point.selectNext")?.canHandleShortcut?.()).toBe(true); + expect(getRegisteredAction("automation.point.selectPrevious")?.canHandleShortcut?.()).toBe(true); + }, + ); +}); + +describe("detached automation action routing", () => { + it("publishes the exact selected tracks and defers project mutation to the main realm", async () => { + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + const detachedMutation = vi.fn(); + + routeAutomationAction( + "automation.selectedTracks.mode.write", + detachedMutation, + "mixer", + ); + + expect(detachedMutation).not.toHaveBeenCalled(); + expect(publish).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledWith({ + command: "action.execute", + actionId: "automation.selectedTracks.mode.write", + selectedTrackIds: ["track-a", "track-b"], + selectedClipIds: [], + timeSelection: null, + }); + + const payload = publish.mock.calls[0][0] as { actionId: string }; + expect(isRemoteAutomationActionId(payload.actionId)).toBe(true); + expect(executeDetachedMainActionRequest(payload, getRegisteredAction, { role: "main" })).toBe(true); + + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks.map((track) => track.automationWriteEnabled)) + .toEqual([true, true]); + await Promise.resolve(); + }); + + it("allowlists a QA all-track mutation and executes it once in the main realm", () => { + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + + routeAutomationAction("automation.allTracks.mode.latch", () => { + throw new Error("detached realm must not mutate project state"); + }, "mixer"); + + expect(publish).toHaveBeenCalledOnce(); + const payload = publish.mock.calls[0][0] as { actionId: string }; + expect(payload.actionId).toBe("automation.allTracks.mode.latch"); + expect(executeDetachedMainActionRequest(payload, getRegisteredAction, { role: "main" })).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks.flatMap((track) => track.automationLanes.map((lane) => lane.mode))) + .toEqual(["latch", "latch"]); + }); + + it("does not forward or execute IDs outside the explicit allowlist", () => { + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + const localMutation = vi.fn(); + + routeAutomationAction("edit.delete", localMutation, "mixer"); + + expect(publish).not.toHaveBeenCalled(); + expect(localMutation).not.toHaveBeenCalled(); + }); + + it("executes an allowlisted action locally exactly once in the main role", () => { + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + const mainMutation = vi.fn(); + + routeAutomationAction( + "automation.selectedTracks.mode.write", + mainMutation, + "main", + ); + + expect(mainMutation).toHaveBeenCalledTimes(1); + expect(publish).not.toHaveBeenCalled(); + }); + + it("behaviorally rejects an invalid receiver ID without mutation", () => { + const beforeTracks = useDAWStore.getState().tracks; + + expect(executeDetachedMainActionRequest({ + command: "action.execute", + actionId: "edit.delete", + selectedTrackIds: [], + selectedClipIds: [], + timeSelection: null, + }, getRegisteredAction, { role: "main" })).toBe(false); + expect(executeDetachedMainActionRequest({ + command: "action.execute", + actionId: "automation.not-a-real-action", + selectedTrackIds: [], + }, getRegisteredAction, { role: "main" })).toBe(false); + expect(useDAWStore.getState().tracks).toBe(beforeTracks); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/automationAudioMidiNamTrackFX.test.ts b/frontend/src/__tests__/automationAudioMidiNamTrackFX.test.ts new file mode 100644 index 0000000..0469fc2 --- /dev/null +++ b/frontend/src/__tests__/automationAudioMidiNamTrackFX.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fxChainPanelSource from "../components/FXChainPanel.tsx?raw"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AutomationLane, + type Track, + useDAWStore, +} from "../store/useDAWStore"; +import { + removeTrackFXAutomationLanes, + reorderTrackFXAutomationLanes, +} from "../store/actions/automation"; + +const initialState = useDAWStore.getState(); + +function makeTrack(id: string, type: "audio" | "midi", lanes: AutomationLane[] = []): Track { + return { + ...createDefaultTrack(id, id, "#4488cc", type, []), + automationLanes: lanes, + }; +} + +function lane(id: string, param: string, value = 0.5): AutomationLane { + return { + id, + param, + points: [{ id: `${id}-point`, time: 1, value }], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }; +} + +function resetStore(tracks: Track[]) { + commandManager.clear(); + useDAWStore.setState({ + tracks, + selectedTrackId: tracks[0]?.id ?? null, + selectedTrackIds: tracks[0] ? [tracks[0].id] : [], + globalLocked: false, + lockSettings: { ...useDAWStore.getState().lockSettings, envelopes: false }, + canUndo: false, + canRedo: false, + isModified: false, + }); +} + +beforeEach(() => { + vi.spyOn(nativeBridge, "setAutomationPoints").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setAutomationMode").mockResolvedValue(true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(initialState); +}); + +describe("audio and MIDI track automation flows", () => { + it.each([ + ["audio", "volume", 0.75, 1, -6], + ["midi", "midi_velocity_scale", 0.75, 1, 1.5], + ["midi", "midi_pitch_bend", 0.75, 1, 0.5], + ["midi", "midi_channel_pressure", 0.75, 1, 95.25], + ["midi", "midi_cc_74", 0.75, 1, 95.25], + ] as const)( + "creates, edits, syncs and undoes a %s %s lane", + (trackType, param, value, time, expectedBackendValue) => { + const track = makeTrack(`track-${trackType}-${param}`, trackType); + resetStore([track]); + + const laneId = useDAWStore.getState().addAutomationLane(track.id, param); + expect(laneId).toBeTruthy(); + useDAWStore.getState().addAutomationPoint(track.id, laneId!, time, value); + + const updatedLane = useDAWStore.getState().tracks[0].automationLanes[0]; + expect(updatedLane).toMatchObject({ param, mode: "read", readEnabled: true }); + expect(updatedLane.points).toHaveLength(1); + expect(nativeBridge.setAutomationPoints).toHaveBeenLastCalledWith( + track.id, + param, + [{ time, value: expectedBackendValue }], + ); + expect(nativeBridge.setAutomationMode).toHaveBeenLastCalledWith(track.id, param, "read"); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points).toEqual([]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points) + .toMatchObject([{ time, value }]); + }, + ); + + it("records one MIDI CC write pass and restores it with one undo", () => { + const midiTrack = makeTrack("midi-write", "midi", [ + lane("cc74", "midi_cc_74", 0.2), + ]); + midiTrack.automationReadEnabled = true; + midiTrack.automationWriteEnabled = true; + midiTrack.automationLanes[0].mode = "touch"; + midiTrack.automationLanes[0].armed = true; + resetStore([midiTrack]); + useDAWStore.setState({ + transport: { ...useDAWStore.getState().transport, isPlaying: true, currentTime: 2 }, + }); + + useDAWStore.getState().beginAutomationParamTouch(midiTrack.id, "midi_cc_74"); + useDAWStore.getState().setAutomationWriteValue(midiTrack.id, "midi_cc_74", 0.8); + useDAWStore.getState().recordAutomationWriteTick(Date.now() + 1000); + useDAWStore.getState().endAutomationParamTouch(midiTrack.id, "midi_cc_74"); + useDAWStore.getState().endAutomationWriteSession(); + + const written = useDAWStore.getState().tracks[0].automationLanes[0].points; + expect(written.some((point) => point.time === 2 && point.value === 0.8)).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points) + .toEqual(midiTrack.automationLanes[0].points); + }); + + it.each(["audio", "midi"] as const)("blocks direct %s automation edits under both automation locks", (type) => { + const track = makeTrack(`locked-${type}`, type, [lane("locked", type === "midi" ? "midi_cc_1" : "volume")]); + resetStore([track]); + + for (const lockedState of [ + { globalLocked: true, envelopes: false }, + { globalLocked: false, envelopes: true }, + ]) { + commandManager.clear(); + useDAWStore.setState({ + globalLocked: lockedState.globalLocked, + lockSettings: { ...useDAWStore.getState().lockSettings, envelopes: lockedState.envelopes }, + }); + useDAWStore.getState().addAutomationPoint(track.id, "locked", 4, 0.9); + useDAWStore.getState().setTrackAutomationMode(track.id, "write"); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points).toEqual(track.automationLanes[0].points); + expect(commandManager.getUndoStack()).toHaveLength(0); + } + }); +}); + +describe("NAM Rack automation in a track FX chain", () => { + it("routes add and reorder UI operations through undo-aware store actions", () => { + expect(fxChainPanelSource).toContain("addTrackBuiltInFXWithUndo("); + expect(fxChainPanelSource).toContain("reorderTrackFXWithUndo("); + expect(fxChainPanelSource).not.toContain("success = await nativeBridge.addTrackBuiltInFX("); + expect(fxChainPanelSource).not.toContain("success = await nativeBridge.reorderTrackFX("); + }); + + it("remaps built-in and hosted automation identities without changing lane or point identity", () => { + const lanes = [ + lane("nam", "builtin_track_0_ampGainDb", 0.75), + lane("hosted", "plugin_track_1_17", 0.25), + lane("input", "builtin_input_0_mix", 0.5), + lane("volume", "volume", 0.5), + ]; + const reordered = reorderTrackFXAutomationLanes(lanes, "track", 0, 1); + expect(reordered.map((entry) => entry.param)).toEqual([ + "builtin_track_1_ampGainDb", + "plugin_track_0_17", + "builtin_input_0_mix", + "volume", + ]); + expect(reordered[0].id).toBe("nam"); + expect(reordered[0].points[0].id).toBe("nam-point"); + + const removed = removeTrackFXAutomationLanes(reordered, "track", 1); + expect(removed.map((entry) => entry.param)).toEqual([ + "plugin_track_0_17", + "builtin_input_0_mix", + "volume", + ]); + }); + + it("adds, automates, reorders, removes and restores the exact NAM Rack instance", async () => { + let slots: Array> = []; + vi.spyOn(nativeBridge, "getTrackFX").mockImplementation(async () => slots.map((slot) => ({ ...slot }))); + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "addTrackBuiltInFX").mockImplementation(async (_trackId, name) => { + slots.push({ index: slots.length, name, pluginPath: name, type: "builtin", bypassed: false, precisionOverride: "auto" }); + return true; + }); + vi.spyOn(nativeBridge, "removeTrackFX").mockImplementation(async (_trackId, index) => { + if (!slots[index]) return false; + slots.splice(index, 1); + slots = slots.map((slot, slotIndex) => ({ ...slot, index: slotIndex })); + return true; + }); + vi.spyOn(nativeBridge, "reorderTrackFX").mockImplementation(async (_trackId, from, to) => { + if (!slots[from] || !slots[to]) return false; + const [moved] = slots.splice(from, 1); + slots.splice(to, 0, moved); + slots = slots.map((slot, slotIndex) => ({ ...slot, index: slotIndex })); + return true; + }); + vi.spyOn(nativeBridge, "getPluginState").mockResolvedValue("NAM-STATE"); + vi.spyOn(nativeBridge, "setPluginState").mockResolvedValue(true); + vi.spyOn(nativeBridge, "bypassTrackFX").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setTrackPluginPrecisionOverride").mockResolvedValue(true); + + const track = makeTrack("nam-track", "audio"); + resetStore([track]); + expect(await useDAWStore.getState().addTrackBuiltInFXWithUndo( + track.id, + "OpenStudio NAM Rack", + "track", + )).toBe(true); + expect(slots.map((slot) => slot.name)).toEqual(["OpenStudio NAM Rack"]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + const namLaneId = useDAWStore.getState().addAutomationLane( + track.id, + "builtin_track_0_ampGainDb", + "Amp Gain", + ); + expect(namLaneId).toBeTruthy(); + useDAWStore.getState().addAutomationPoint(track.id, namLaneId!, 1, 0.75); + expect(nativeBridge.setAutomationPoints).toHaveBeenLastCalledWith( + track.id, + "builtin_track_0_ampGainDb", + [{ time: 1, value: 0.75 }], + ); + const originalLane = structuredClone( + useDAWStore.getState().tracks[0].automationLanes[0], + ); + slots.push({ index: 1, name: "OpenStudio Delay", pluginPath: "OpenStudio Delay", type: "builtin", bypassed: false, precisionOverride: "auto" }); + commandManager.clear(); + + expect(await useDAWStore.getState().reorderTrackFXWithUndo(track.id, 0, 1, "track")).toBe(true); + expect(slots.map((slot) => slot.name)).toEqual(["OpenStudio Delay", "OpenStudio NAM Rack"]); + expect(useDAWStore.getState().tracks[0].automationLanes[0]).toMatchObject({ + id: namLaneId, + param: "builtin_track_1_ampGainDb", + }); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + await vi.waitFor(() => { + expect(slots.map((slot) => slot.name)).toEqual(["OpenStudio NAM Rack", "OpenStudio Delay"]); + expect(useDAWStore.getState().tracks[0].automationLanes[0].param).toBe("builtin_track_0_ampGainDb"); + }); + useDAWStore.getState().redo(); + await vi.waitFor(() => { + expect(slots.map((slot) => slot.name)).toEqual(["OpenStudio Delay", "OpenStudio NAM Rack"]); + expect(useDAWStore.getState().tracks[0].automationLanes[0].param).toBe("builtin_track_1_ampGainDb"); + }); + + commandManager.clear(); + const reorderedLane = structuredClone( + useDAWStore.getState().tracks[0].automationLanes[0], + ); + expect(await useDAWStore.getState().removeTrackFXWithUndo(track.id, 1, "track")).toBe(true); + expect(slots.map((slot) => slot.name)).toEqual(["OpenStudio Delay"]); + expect(useDAWStore.getState().tracks[0].automationLanes).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(slots.map((slot) => slot.name)).toEqual(["OpenStudio Delay", "OpenStudio NAM Rack"])); + expect(useDAWStore.getState().tracks[0].automationLanes[0]).toMatchObject({ + id: namLaneId, + param: "builtin_track_1_ampGainDb", + }); + expect(useDAWStore.getState().tracks[0].automationLanes[0]).toEqual(reorderedLane); + expect(originalLane).toMatchObject({ id: namLaneId, param: "builtin_track_0_ampGainDb" }); + expect(nativeBridge.setPluginState).toHaveBeenCalledWith(track.id, 1, false, "NAM-STATE"); + }); +}); diff --git a/frontend/src/__tests__/automationCubaseReadWrite.test.ts b/frontend/src/__tests__/automationCubaseReadWrite.test.ts index bb9ca26..65d0841 100644 --- a/frontend/src/__tests__/automationCubaseReadWrite.test.ts +++ b/frontend/src/__tests__/automationCubaseReadWrite.test.ts @@ -32,6 +32,15 @@ function volumeLane(overrides: Partial = {}): AutomationLane { }; } +function expectStablePointValues( + points: AutomationLane["points"] | undefined, + expected: Array<{ time: number; value: number }>, +) { + expect(points?.map(({ time, value }) => ({ time, value }))).toEqual(expected); + expect(points?.every((point) => typeof point.id === "string" && point.id.length > 0)).toBe(true); + expect(new Set(points?.map((point) => point.id)).size).toBe(expected.length); +} + function loadTrack(track: Track, isPlaying = false, currentTime = 1) { useDAWStore.setState({ tracks: [track], @@ -172,7 +181,7 @@ describe("Cubase-style automation read/write state", () => { expect(updated.automationLanes).toHaveLength(1); expect(updated.automationLanes[0].readEnabled).toBe(true); expect(updated.automationLanes[0].mode).toBe("off"); - expect(updated.automationLanes[0].points).toEqual([{ time: 2, value: 0.75 }]); + expectStablePointValues(updated.automationLanes[0].points, [{ time: 2, value: 0.75 }]); }); it("write enabled with no touched parameter writes no points", () => { @@ -253,7 +262,7 @@ describe("Cubase-style automation read/write state", () => { expect(updated.showAutomation).toBe(true); expect(lane?.visible).toBe(true); expect(lane?.readEnabled).toBe(true); - expect(lane?.points).toEqual([{ time: 3, value: 0.75 }]); + expectStablePointValues(lane?.points, [{ time: 3, value: 0.75 }]); }); it("continuous touch writing simplifies simple ramps into sparse points", () => { @@ -281,7 +290,8 @@ describe("Cubase-style automation read/write state", () => { const points = useDAWStore.getState().tracks[0].automationLanes[0].points; expect(points.length).toBeLessThanOrEqual(6); - expect(points[0]).toEqual({ time: 0, value: 0 }); + expect(points[0]).toMatchObject({ time: 0, value: 0 }); + expect(points.every((point) => typeof point.id === "string" && point.id.length > 0)).toBe(true); expect(points[points.length - 1].time).toBeCloseTo(2); expect(points[points.length - 1].value).toBeCloseTo(1); }); @@ -322,7 +332,7 @@ describe("Cubase-style automation read/write state", () => { let updated = useDAWStore.getState().tracks[0]; expect(updated.automationReadEnabled).toBe(true); expect(updated.automationLanes[0].readEnabled).toBe(true); - expect(updated.automationLanes[0].points).toEqual([{ time: 1.25, value: 0.5 }]); + expectStablePointValues(updated.automationLanes[0].points, [{ time: 1.25, value: 0.5 }]); useDAWStore.getState().undo(); @@ -354,7 +364,7 @@ describe("Cubase-style automation read/write state", () => { const updated = useDAWStore.getState().tracks[0]; expect(updated.automationWriteEnabled).toBe(true); - expect(updated.automationLanes[0].points).toEqual([{ time: 1.25, value: 0.5 }]); + expectStablePointValues(updated.automationLanes[0].points, [{ time: 1.25, value: 0.5 }]); expect(_automationTouchedParams.has(key)).toBe(false); expect(_automationLatchedParams.has(key)).toBe(false); expect(_automationWriteValues.has(key)).toBe(false); @@ -385,7 +395,7 @@ describe("Cubase-style automation read/write state", () => { let updated = useDAWStore.getState().tracks[0]; expect(updated.automationWriteEnabled).toBe(true); expect(updated.automationLanes[0].readEnabled).toBe(true); - expect(updated.automationLanes[0].points).toEqual([ + expectStablePointValues(updated.automationLanes[0].points, [ { time: 1, value: 0.25 }, { time: 1.5, value: 0.75 }, ]); @@ -410,7 +420,7 @@ describe("Cubase-style automation read/write state", () => { const updated = useDAWStore.getState().tracks[0]; const muteLane = updated.automationLanes.find((lane) => lane.param === "mute"); expect(updated.muted).toBe(true); - expect(muteLane?.points).toEqual([{ time: 5, value: 1 }]); + expectStablePointValues(muteLane?.points, [{ time: 5, value: 1 }]); }); it("toggle mute does not create write data while stopped", async () => { @@ -476,7 +486,7 @@ describe("Cubase-style automation read/write state", () => { useDAWStore.getState().endAutomationParamTouch("master", "pan"); const lane = useDAWStore.getState().masterAutomationLanes.find((candidate) => candidate.param === "pan"); - expect(lane?.points).toEqual([{ time: 7, value: 0.625 }]); + expectStablePointValues(lane?.points, [{ time: 7, value: 0.625 }]); }); it("master write while stopped does not create automation lanes", async () => { @@ -526,6 +536,6 @@ describe("Cubase-style automation read/write state", () => { expect(state.masterAutomationReadEnabled).toBe(false); expect(state.masterAutomationWriteEnabled).toBe(true); expect(lane?.mode).toBe("off"); - expect(lane?.points).toEqual([{ time: 9, value: 60 / 72 }]); + expectStablePointValues(lane?.points, [{ time: 9, value: 60 / 72 }]); }); }); diff --git a/frontend/src/__tests__/automationInteractionCommands.test.ts b/frontend/src/__tests__/automationInteractionCommands.test.ts new file mode 100644 index 0000000..380c153 --- /dev/null +++ b/frontend/src/__tests__/automationInteractionCommands.test.ts @@ -0,0 +1,673 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + _autoRecordTimers, + _automationLatchedParams, + _automationTouchedParams, + _automationWriteValues, +} from "../store/actions/storeHelpers"; +import { + createDefaultTrack, + type AutomationLane, + type AutomationPoint, + type AutomationSelectionTarget, + type Track, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function point(id: string, time: number, value: number): AutomationPoint { + return { id, time, value }; +} + +function lane( + id: string, + param: string, + points: AutomationPoint[] = [], + overrides: Partial = {}, +): AutomationLane { + return { + id, + param, + points, + visible: true, + mode: "read", + armed: false, + readEnabled: true, + ...overrides, + }; +} + +function track( + id: string, + automationLanes: AutomationLane[], + overrides: Partial = {}, +): Track { + return { + ...createDefaultTrack(id, id, "#14b8a6", "audio", []), + automationLanes, + showAutomation: true, + automationReadEnabled: true, + automationWriteEnabled: false, + automationEnabled: true, + suspendedAutomationState: null, + ...overrides, + }; +} + +function trackLane(trackId = "track-a", laneId = "track-volume") { + return useDAWStore.getState().tracks + .find((candidate) => candidate.id === trackId) + ?.automationLanes.find((candidate) => candidate.id === laneId); +} + +function resetAutomationRuntime() { + useDAWStore.getState().cancelAutomationPointEdit?.(); + useDAWStore.getState().endAutomationWriteSession?.(); + _automationTouchedParams.clear(); + _automationLatchedParams.clear(); + _automationWriteValues.clear(); + _autoRecordTimers.clear(); + commandManager.clear(); +} + +beforeEach(() => { + resetAutomationRuntime(); + vi.spyOn(nativeBridge, "setAutomationPoints").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setAutomationMode").mockResolvedValue(true); + vi.spyOn(nativeBridge, "clearAutomation").mockResolvedValue(true); + vi.spyOn(nativeBridge, "replaceAutomationPointsInRange").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [], + selectedTrackId: null, + selectedTrackIds: [], + selectedAutomationTarget: null, + masterAutomationLanes: [], + showMasterAutomation: false, + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + suspendedMasterAutomationState: null, + automationWriteBehavior: "touch", + globalLocked: false, + lockSettings: { + ...useDAWStore.getState().lockSettings, + envelopes: false, + }, + transport: { + ...useDAWStore.getState().transport, + isPlaying: false, + currentTime: 0, + }, + canUndo: false, + canRedo: false, + isModified: false, + }); +}); + +afterEach(() => { + resetAutomationRuntime(); + vi.restoreAllMocks(); + useDAWStore.setState(originalState); +}); + +describe("automation point identity and edit transactions", () => { + it("keeps the selected point ID when it crosses neighbours and commits one undo command", () => { + const originalPoints = [ + point("moving", 1, 0.1), + point("middle", 2, 0.2), + point("last", 3, 0.3), + ]; + useDAWStore.setState({ + tracks: [track("track-a", [lane("track-volume", "volume", originalPoints)])], + }); + const target: AutomationSelectionTarget = { + kind: "track", + trackId: "track-a", + laneId: "track-volume", + pointId: "moving", + }; + + expect(useDAWStore.getState().beginAutomationPointEdit(target)).toBe(true); + expect(useDAWStore.getState().previewAutomationPointEdit(4, 0.9)).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().commitAutomationPointEdit()).toBe(true); + + expect(trackLane()?.points).toEqual([ + point("middle", 2, 0.2), + point("last", 3, 0.3), + point("moving", 4, 0.9), + ]); + expect(useDAWStore.getState().selectedAutomationTarget).toEqual(target); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(trackLane()?.points).toEqual(originalPoints); + expect(useDAWStore.getState().selectedAutomationTarget).toEqual(target); + + useDAWStore.getState().redo(); + expect(trackLane()?.points.map((candidate) => candidate.id)).toEqual([ + "middle", + "last", + "moving", + ]); + }); + + it("restores the exact preview snapshot on cancel without creating history", () => { + const originalPoints = [point("first", 1, 0.2), point("second", 2, 0.8)]; + useDAWStore.setState({ + tracks: [track("track-a", [lane("track-volume", "volume", originalPoints)])], + isModified: false, + }); + const target: AutomationSelectionTarget = { + kind: "track", + trackId: "track-a", + laneId: "track-volume", + pointId: "second", + }; + + expect(useDAWStore.getState().beginAutomationPointEdit(target)).toBe(true); + expect(useDAWStore.getState().previewAutomationPointEdit(0.25, 0.1)).toBe(true); + expect(trackLane()?.points).not.toEqual(originalPoints); + expect(useDAWStore.getState().cancelAutomationPointEdit()).toBe(true); + + expect(trackLane()?.points).toEqual(originalPoints); + expect(useDAWStore.getState().isModified).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("uses the same one-command stable-ID transaction for master points", () => { + const originalPoints = [point("master-first", 1, 0.25), point("master-moving", 2, 0.75)]; + useDAWStore.setState({ + masterAutomationLanes: [lane("master-volume", "volume", originalPoints)], + }); + const target: AutomationSelectionTarget = { + kind: "master", + laneId: "master-volume", + pointId: "master-moving", + }; + + expect(useDAWStore.getState().beginAutomationPointEdit(target)).toBe(true); + expect(useDAWStore.getState().previewAutomationPointEdit(0.5, 0.6)).toBe(true); + expect(useDAWStore.getState().commitAutomationPointEdit()).toBe(true); + + expect(useDAWStore.getState().masterAutomationLanes[0].points).toEqual([ + point("master-moving", 0.5, 0.6), + point("master-first", 1, 0.25), + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().masterAutomationLanes[0].points).toEqual(originalPoints); + }); +}); + +describe("automation envelope locking", () => { + it("blocks track and master point mutations while envelopes are locked", () => { + const trackPoints = [point("track-point", 1, 0.25)]; + const masterPoints = [point("master-point", 2, 0.75)]; + useDAWStore.setState({ + tracks: [track("track-a", [lane("track-volume", "volume", trackPoints)])], + masterAutomationLanes: [lane("master-volume", "volume", masterPoints)], + selectedAutomationTarget: { + kind: "track", + trackId: "track-a", + laneId: "track-volume", + pointId: "track-point", + }, + lockSettings: { ...useDAWStore.getState().lockSettings, envelopes: true }, + }); + + useDAWStore.getState().addAutomationPoint("track-a", "track-volume", 3, 0.5); + useDAWStore.getState().removeAutomationPoint("track-a", "track-volume", 0); + useDAWStore.getState().moveAutomationPoint("track-a", "track-volume", 0, 4, 1); + useDAWStore.getState().clearAutomationLane("track-a", "track-volume"); + useDAWStore.getState().deleteSelectedAutomationPoint(); + useDAWStore.getState().addMasterAutomationPoint("master-volume", 3, 0.5); + useDAWStore.getState().removeMasterAutomationPoint("master-volume", 0); + useDAWStore.getState().moveMasterAutomationPoint("master-volume", 0, 4, 0); + useDAWStore.getState().clearMasterAutomationLane("master-volume"); + + expect(useDAWStore.getState().beginAutomationPointEdit({ + kind: "master", + laneId: "master-volume", + pointId: "master-point", + })).toBe(false); + expect(trackLane()?.points).toEqual(trackPoints); + expect(useDAWStore.getState().masterAutomationLanes[0].points).toEqual(masterPoints); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("allows existing point commands to undo after the envelope lock is enabled", () => { + useDAWStore.setState({ + tracks: [track("track-a", [lane("track-volume", "volume", [point("track-point", 1, 0.25)])])], + masterAutomationLanes: [lane("master-volume", "volume", [point("master-point", 2, 0.75)])], + }); + + useDAWStore.getState().moveAutomationPoint("track-a", "track-volume", 0, 3, 0.5); + useDAWStore.getState().moveMasterAutomationPoint("master-volume", 0, 4, 0.25); + expect(commandManager.getUndoStack()).toHaveLength(2); + + useDAWStore.setState({ + lockSettings: { ...useDAWStore.getState().lockSettings, envelopes: true }, + }); + useDAWStore.getState().undo(); + useDAWStore.getState().undo(); + + expect(trackLane()?.points).toEqual([point("track-point", 1, 0.25)]); + expect(useDAWStore.getState().masterAutomationLanes[0].points).toEqual([ + point("master-point", 2, 0.75), + ]); + }); + + it("treats Global Lock as an umbrella for track/master point, draw, copy, write, and suspend paths", () => { + const trackPoints = [point("track-point", 1, 0.25)]; + const masterPoints = [point("master-point", 2, 0.75)]; + useDAWStore.setState({ + tracks: [track("track-a", [lane("track-volume", "volume", trackPoints)], { + automationWriteEnabled: true, + })], + masterAutomationLanes: [lane("master-volume", "volume", masterPoints)], + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: true, + masterAutomationEnabled: true, + selectedAutomationTarget: { + kind: "track", + trackId: "track-a", + laneId: "track-volume", + pointId: "track-point", + }, + globalLocked: true, + transport: { + ...useDAWStore.getState().transport, + isPlaying: true, + currentTime: 3, + }, + }); + + useDAWStore.getState().addAutomationPoint("track-a", "track-volume", 3, 0.5); + useDAWStore.getState().removeAutomationPoint("track-a", "track-volume", 0); + useDAWStore.getState().moveAutomationPoint("track-a", "track-volume", 0, 4, 1); + useDAWStore.getState().setAutomationLanePoints("track-a", "track-volume", []); + useDAWStore.getState().deleteSelectedAutomationPoint(); + useDAWStore.getState().addAutomationPointAtPlayhead(); + expect(useDAWStore.getState().beginAutomationPointEdit({ + kind: "track", + trackId: "track-a", + laneId: "track-volume", + pointId: "track-point", + })).toBe(false); + expect(useDAWStore.getState().beginAutomationPointCopyEdit({ + kind: "track", + trackId: "track-a", + laneId: "track-volume", + pointId: "track-point", + })).toBe(false); + + useDAWStore.getState().addMasterAutomationPoint("master-volume", 3, 0.5); + useDAWStore.getState().removeMasterAutomationPoint("master-volume", 0); + useDAWStore.getState().moveMasterAutomationPoint("master-volume", 0, 4, 0); + useDAWStore.getState().clearMasterAutomationLane("master-volume"); + expect(useDAWStore.getState().addAutomationLane("track-a", "pan")).toBeNull(); + expect(useDAWStore.getState().addMasterAutomationLane("pan")).toBeNull(); + + useDAWStore.getState().beginAutomationParamTouch("track-a", "volume"); + useDAWStore.getState().setAutomationWriteValue("track-a", "volume", 0.9); + useDAWStore.getState().recordAutomationWriteTick(1_000); + useDAWStore.getState().suspendAutomation(); + + expect(getRegisteredAction("automation.point.deleteSelected")!.canHandleShortcut?.()).toBe(false); + expect(getRegisteredAction("automation.selectedLane.clear")!.canHandleShortcut?.()).toBe(false); + expect(getRegisteredAction("automation.suspend")!.canHandleShortcut?.()).toBe(false); + + expect(trackLane()?.points).toEqual(trackPoints); + expect(useDAWStore.getState().masterAutomationLanes[0].points).toEqual(masterPoints); + expect(useDAWStore.getState().tracks[0].automationLanes).toHaveLength(1); + expect(useDAWStore.getState().masterAutomationLanes).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].suspendedAutomationState).toBeNull(); + expect(useDAWStore.getState().suspendedMasterAutomationState).toBeNull(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it.each([ + ["Global Lock", { globalLocked: true }], + ["Envelope Lock", { lockSettings: { envelopes: true } }], + ] as const)("blocks every track/master automation mode mutation under %s", (_label, lock) => { + const trackLanes = [ + lane("track-volume", "volume", [point("track-point", 1, 0.25)], { armed: true }), + lane("track-pan", "pan", [], { armed: false }), + ]; + const masterLanes = [ + lane("master-volume", "volume", [point("master-point", 2, 0.75)], { armed: true }), + lane("master-pan", "pan", [], { armed: false }), + ]; + useDAWStore.setState((state) => ({ + tracks: [track("track-a", trackLanes)], + selectedTrackId: "track-a", + selectedTrackIds: ["track-a"], + selectedAutomationTarget: { + kind: "track", + trackId: "track-a", + laneId: "track-volume", + pointId: "track-point", + }, + masterAutomationLanes: masterLanes, + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: true, + globalLocked: "globalLocked" in lock ? lock.globalLocked : false, + lockSettings: { + ...state.lockSettings, + envelopes: "lockSettings" in lock ? lock.lockSettings.envelopes : false, + }, + })); + + const automationSnapshot = () => { + const state = useDAWStore.getState(); + return JSON.stringify({ + automationWriteBehavior: state.automationWriteBehavior, + tracks: state.tracks, + masterAutomationLanes: state.masterAutomationLanes, + showMasterAutomation: state.showMasterAutomation, + masterAutomationReadEnabled: state.masterAutomationReadEnabled, + masterAutomationWriteEnabled: state.masterAutomationWriteEnabled, + masterAutomationEnabled: state.masterAutomationEnabled, + }); + }; + const before = automationSnapshot(); + const state = useDAWStore.getState(); + + state.setAutomationWriteBehavior("latch"); + state.setTrackAutomationRead("track-a", false); + state.toggleTrackAutomationRead("track-a"); + state.setTrackAutomationWrite("track-a", true); + state.toggleTrackAutomationWrite("track-a"); + state.setAutomationLaneRead("track-a", "track-volume", false); + state.setAutomationLaneMode("track-a", "track-volume", "write"); + state.toggleTrackAutomation("track-a"); + state.toggleAutomationLaneVisibility("track-a", "track-volume"); + state.setSelectedAutomationLaneVisibility(false); + state.setTrackAutomationMode("track-a", "write"); + state.armAutomationLane("track-a", "track-volume", false); + state.armAllVisibleAutomationLanes("track-a"); + state.disarmAllAutomationLanes("track-a"); + state.setTracksAutomationRead(["track-a"], false); + state.toggleTracksAutomationRead(["track-a"]); + state.setTracksAutomationWrite(["track-a"], true); + state.toggleTracksAutomationWrite(["track-a"]); + state.setTracksAutomationMode(["track-a"], "latch"); + state.setTracksAutomationVisibility(["track-a"], false); + state.setMasterAutomationRead(false); + state.toggleMasterAutomationRead(); + state.setMasterAutomationWrite(true); + state.toggleMasterAutomationWrite(); + state.setMasterAutomationLaneRead("master-volume", false); + state.setMasterAutomationLaneMode("master-volume", "write"); + state.armMasterAutomationLane("master-volume", false); + state.toggleMasterAutomation(); + state.toggleMasterAutomationLaneVisibility("master-volume"); + state.setMasterTrackAutomationMode("latch"); + state.armAllVisibleMasterAutomationLanes(); + state.disarmAllMasterAutomationLanes(); + + expect(automationSnapshot()).toBe(before); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("keeps only non-project arrangement and selection navigation available while locked", () => { + useDAWStore.setState((state) => ({ + tracks: [track("track-a", [lane("track-volume", "volume")], { showAutomation: true })], + selectedTrackId: "track-a", + selectedTrackIds: ["track-a"], + selectedAutomationTarget: { + kind: "track", + trackId: "track-a", + laneId: "track-volume", + pointId: null, + }, + lockSettings: { ...state.lockSettings, envelopes: true }, + })); + + expect(getRegisteredAction("automation.selectedTracks.show")?.canHandleShortcut?.()).toBe(false); + expect(getRegisteredAction("automation.selectedLane.show")?.canHandleShortcut?.()).toBe(false); + expect(getRegisteredAction("automation.toggleArrangementView")?.canHandleShortcut?.()).toBe(true); + expect(getRegisteredAction("automation.lane.selectNext")?.canHandleShortcut?.()).toBe(true); + + getRegisteredAction("automation.toggleArrangementView")?.execute(); + expect(useDAWStore.getState().tracks[0].showAutomation).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); + +describe("automation project commands", () => { + it("clears a newly-added track lane from the backend when lane creation is undone", () => { + useDAWStore.setState({ tracks: [track("track-a", [], { + showAutomation: false, + automationReadEnabled: false, + automationEnabled: false, + })] }); + const clearSpy = vi.mocked(nativeBridge.clearAutomation); + + const laneId = useDAWStore.getState().addAutomationLane("track-a", "volume"); + expect(laneId).toBeTypeOf("string"); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + + expect(trackLane("track-a", laneId as string)).toBeUndefined(); + expect(clearSpy).toHaveBeenCalledWith("track-a", "volume"); + }); + + it("clears a newly-added master lane from the backend when lane creation is undone", () => { + const clearSpy = vi.mocked(nativeBridge.clearAutomation); + + const laneId = useDAWStore.getState().addMasterAutomationLane("pan"); + expect(laneId).toBe("master-pan"); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + + expect(useDAWStore.getState().masterAutomationLanes).toHaveLength(0); + expect(clearSpy).toHaveBeenCalledWith("master", "pan"); + }); + + it("applies selected-track mode and visibility changes atomically and skips no-ops", () => { + const tracks = ["track-a", "track-b"].map((trackId) => track( + trackId, + [lane(`${trackId}-volume`, "volume", [point(`${trackId}-point`, 1, 0.5)], { + visible: false, + })], + { showAutomation: false }, + )); + useDAWStore.setState({ tracks }); + + useDAWStore.getState().setTracksAutomationMode(["track-a", "track-b"], "write"); + useDAWStore.getState().setTracksAutomationMode(["track-a", "track-b"], "write"); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks.every((candidate) => ( + candidate.automationReadEnabled + && candidate.automationWriteEnabled + && candidate.automationLanes[0].mode === "write" + && candidate.automationLanes[0].armed + ))).toBe(true); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.every((candidate) => ( + candidate.automationLanes[0].mode === "read" && !candidate.automationWriteEnabled + ))).toBe(true); + + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + useDAWStore.getState().setTracksAutomationVisibility(["track-a", "track-b"], true); + useDAWStore.getState().setTracksAutomationVisibility(["track-a", "track-b"], true); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks.every((candidate) => ( + candidate.showAutomation && candidate.automationLanes[0].visible + ))).toBe(true); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.every((candidate) => ( + !candidate.showAutomation && !candidate.automationLanes[0].visible + ))).toBe(true); + }); + + it("toggles arrangement automation as view state without changing lane visibility or history", () => { + useDAWStore.setState({ + tracks: [track("track-a", [lane("track-volume", "volume", [], { visible: false })])], + showMasterAutomation: false, + masterAutomationLanes: [lane("master-pan", "pan", [], { visible: true })], + }); + + useDAWStore.getState().toggleArrangementAutomationView(); + expect(useDAWStore.getState().tracks[0].showAutomation).toBe(false); + expect(useDAWStore.getState().showMasterAutomation).toBe(false); + expect(trackLane()?.visible).toBe(false); + expect(useDAWStore.getState().masterAutomationLanes[0].visible).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.getState().toggleArrangementAutomationView(); + expect(useDAWStore.getState().tracks[0].showAutomation).toBe(true); + expect(useDAWStore.getState().showMasterAutomation).toBe(true); + expect(trackLane()?.visible).toBe(false); + expect(useDAWStore.getState().masterAutomationLanes[0].visible).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("suspends and resumes track and master automation without duplicate history", () => { + useDAWStore.setState({ + tracks: [track("track-a", [lane("track-volume", "volume", [point("track-point", 1, 0.4)], { + mode: "touch", + armed: true, + })], { automationWriteEnabled: true })], + showMasterAutomation: true, + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: true, + masterAutomationEnabled: true, + masterAutomationLanes: [lane("master-pan", "pan", [point("master-point", 1, 0.6)], { + mode: "latch", + armed: true, + })], + }); + + useDAWStore.getState().suspendAutomation(); + useDAWStore.getState().suspendAutomation(); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(trackLane()?.mode).toBe("off"); + expect(trackLane()?.readEnabled).toBe(false); + expect(useDAWStore.getState()).toMatchObject({ + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + }); + expect(useDAWStore.getState().tracks[0].suspendedAutomationState).not.toBeNull(); + expect(useDAWStore.getState().suspendedMasterAutomationState).not.toBeNull(); + + useDAWStore.getState().resumeAutomation(); + useDAWStore.getState().resumeAutomation(); + expect(commandManager.getUndoStack()).toHaveLength(2); + expect(trackLane()).toMatchObject({ mode: "touch", readEnabled: true, armed: true }); + expect(useDAWStore.getState().masterAutomationLanes[0]).toMatchObject({ + mode: "latch", + readEnabled: true, + armed: true, + }); + expect(useDAWStore.getState().tracks[0].suspendedAutomationState).toBeNull(); + expect(useDAWStore.getState().suspendedMasterAutomationState).toBeNull(); + + useDAWStore.getState().undo(); + expect(trackLane()?.mode).toBe("off"); + useDAWStore.getState().undo(); + expect(trackLane()).toMatchObject({ mode: "touch", readEnabled: true, armed: true }); + }); +}); + +describe("automation selection and recorded passes", () => { + it("cycles lanes and point IDs, wraps lanes, and clears stale targets safely", () => { + useDAWStore.setState({ + tracks: [track("track-a", [ + lane("lane-a", "volume", [point("a1", 1, 0.1), point("a2", 2, 0.2)]), + lane("lane-b", "pan", [point("b1", 1, 0.3)]), + ])], + }); + useDAWStore.getState().setSelectedAutomationLane({ + kind: "track", + trackId: "track-a", + laneId: "lane-a", + }); + + useDAWStore.getState().selectAdjacentAutomationPoint("next"); + expect(useDAWStore.getState().selectedAutomationTarget?.pointId).toBe("a1"); + useDAWStore.getState().selectAdjacentAutomationPoint("next"); + expect(useDAWStore.getState().selectedAutomationTarget?.pointId).toBe("a2"); + useDAWStore.getState().selectAdjacentAutomationLane("next"); + expect(useDAWStore.getState().selectedAutomationTarget).toMatchObject({ + laneId: "lane-b", + pointId: null, + }); + useDAWStore.getState().selectAdjacentAutomationLane("next"); + expect(useDAWStore.getState().selectedAutomationTarget?.laneId).toBe("lane-a"); + useDAWStore.getState().selectAdjacentAutomationLane("previous"); + expect(useDAWStore.getState().selectedAutomationTarget?.laneId).toBe("lane-b"); + + useDAWStore.setState({ + selectedAutomationTarget: { + kind: "track", + trackId: "missing", + laneId: "missing", + pointId: "missing", + }, + }); + useDAWStore.getState().selectAdjacentAutomationPoint("next"); + expect(useDAWStore.getState().selectedAutomationTarget).toBeNull(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("records track and master write data as one undoable pass", () => { + const beforeTrack = [point("track-before", 0, 0.2)]; + const beforeMaster = [point("master-before", 0, 0.4)]; + useDAWStore.setState({ + tracks: [track("track-a", [lane("track-volume", "volume", beforeTrack, { + mode: "touch", + })], { automationWriteEnabled: true })], + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: true, + masterAutomationEnabled: true, + masterAutomationLanes: [lane("master-pan", "pan", beforeMaster, { mode: "touch" })], + transport: { + ...useDAWStore.getState().transport, + isPlaying: true, + currentTime: 2, + }, + }); + + useDAWStore.getState().beginAutomationParamTouch("track-a", "volume"); + useDAWStore.getState().beginAutomationParamTouch("master", "pan"); + useDAWStore.getState().setAutomationWriteValue("track-a", "volume", 0.7); + useDAWStore.getState().setAutomationWriteValue("master", "pan", 0.8); + useDAWStore.getState().recordAutomationWriteTick(1_000); + expect(commandManager.getUndoStack()).toHaveLength(0); + useDAWStore.getState().endAutomationWriteSession(); + + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(commandManager.getUndoStack()[0]?.type).toBe("RECORD_AUTOMATION_WRITE_PASS"); + expect(trackLane()?.points.map(({ time, value }) => ({ time, value }))).toEqual([ + { time: 0, value: 0.2 }, + { time: 2, value: 0.7 }, + ]); + expect(useDAWStore.getState().masterAutomationLanes[0].points + .map(({ time, value }) => ({ time, value }))).toEqual([ + { time: 0, value: 0.4 }, + { time: 2, value: 0.8 }, + ]); + + useDAWStore.getState().undo(); + expect(trackLane()?.points).toEqual(beforeTrack); + expect(useDAWStore.getState().masterAutomationLanes[0].points).toEqual(beforeMaster); + useDAWStore.getState().redo(); + const redoneTrackPoints = trackLane()?.points ?? []; + const redoneMasterPoints = useDAWStore.getState().masterAutomationLanes[0].points; + expect(redoneTrackPoints[redoneTrackPoints.length - 1]).toMatchObject({ time: 2, value: 0.7 }); + expect(redoneMasterPoints[redoneMasterPoints.length - 1]) + .toMatchObject({ time: 2, value: 0.8 }); + }); +}); diff --git a/frontend/src/__tests__/automationLaneHeightIntegration.test.tsx b/frontend/src/__tests__/automationLaneHeightIntegration.test.tsx new file mode 100644 index 0000000..8361342 --- /dev/null +++ b/frontend/src/__tests__/automationLaneHeightIntegration.test.tsx @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { TrackHeader } from "../components/TrackHeader"; +import { commandManager } from "../store/commands"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; + +const initialState = useDAWStore.getState(); + +beforeEach(() => { + commandManager.clear(); +}); + +afterEach(() => { + commandManager.clear(); + useDAWStore.setState(initialState); +}); + +describe("automation lane height UI integration", () => { + it("renders the hovered lane at the height stored by the wheel action", () => { + const track = createDefaultTrack("track-1", "Track 1", "#3b82f6", "audio"); + track.showAutomation = true; + track.automationLanes = [{ + id: "volume-lane", + param: "volume", + points: [], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }]; + useDAWStore.setState({ tracks: [track] }); + + const state = useDAWStore.getState(); + state.beginAutomationLaneHeightEdit("track-1", "volume-lane"); + state.setAutomationLaneHeight("track-1", "volume-lane", 96); + state.commitAutomationLaneHeightEdit("track-1", "volume-lane"); + const updatedTrack = useDAWStore.getState().tracks[0]; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-automation-lane-id="volume-lane"'); + expect(html).toMatch( + /data-automation-lane-id="volume-lane"[^>]*style="height:96px"/, + ); + }); +}); diff --git a/frontend/src/__tests__/automationMoveWithItems.test.ts b/frontend/src/__tests__/automationMoveWithItems.test.ts new file mode 100644 index 0000000..bccfaa1 --- /dev/null +++ b/frontend/src/__tests__/automationMoveWithItems.test.ts @@ -0,0 +1,522 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { + copyAutomationPointsWithClips, + moveAutomationPointsWithClips, + shouldInvertAutomationFollowForClipDrag, + shouldMoveAutomationWithItems, + type AutomationClipMove, +} from "../store/actions/clipEditing"; +import { + createDefaultTrack, + type AudioClip, + type AutomationLane, + type AutomationPoint, + type Track, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function clip(id: string, startTime: number, duration = 2): AudioClip { + return { + id, + name: id, + filePath: `C:/audio/${id}.wav`, + startTime, + duration, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }; +} + +function lane(points: AutomationPoint[], id = "volume-lane"): AutomationLane { + return { + id, + param: "volume", + points, + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }; +} + +function track( + id: string, + clips: AudioClip[], + points: AutomationPoint[], + includeLane = true, +): Track { + return { + ...createDefaultTrack(id, id, "#38bdf8", "audio", []), + clips, + automationLanes: includeLane ? [lane(points, `${id}-volume`)] : [], + showAutomation: true, + automationReadEnabled: true, + automationEnabled: true, + }; +} + +function cloneTracks(tracks: Track[]): Track[] { + return tracks.map((candidate) => ({ + ...candidate, + clips: candidate.clips.map((item) => ({ ...item })), + midiClips: candidate.midiClips.map((item) => ({ ...item })), + automationLanes: candidate.automationLanes.map((automationLane) => ({ + ...automationLane, + points: automationLane.points.map((point) => ({ ...point })), + })), + })); +} + +function pointTimes(trackId: string) { + return useDAWStore.getState().tracks + .find((candidate) => candidate.id === trackId) + ?.automationLanes.find((candidate) => candidate.param === "volume") + ?.points.map((point) => [point.id, Number(point.time.toFixed(6))]); +} + +beforeEach(() => { + commandManager.clear(); + vi.spyOn(nativeBridge, "setAutomationPoints").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setAutomationMode").mockResolvedValue(true); + vi.spyOn(nativeBridge, "clearAutomation").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [], + selectedClipId: null, + selectedClipIds: [], + moveEnvelopesWithItems: true, + autoCrossfade: false, + lockSettings: { + ...useDAWStore.getState().lockSettings, + envelopes: false, + }, + syncClipsWithBackend: vi.fn().mockResolvedValue(undefined), + canUndo: false, + canRedo: false, + }); +}); + +afterEach(() => { + commandManager.clear(); + vi.restoreAllMocks(); + useDAWStore.setState(originalState); +}); + +describe("automation follows item movement", () => { + it("fans one source interval out to every repeat with fresh stable point ids", () => { + const source = [track("track-a", [clip("clip-a", 1, 1)], [ + { id: "source", time: 1.5, value: 0.5 }, + { id: "outside", time: 5, value: 0.8 }, + ])]; + const current = cloneTracks(source); + current[0].clips.push(clip("copy-a", 2, 1), clip("copy-b", 3, 1)); + const copied = copyAutomationPointsWithClips(current, [ + { + clipId: "clip-a", + sourceTrackId: "track-a", + targetTrackId: "track-a", + originalStartTime: 1, + newStartTime: 2, + duration: 1, + }, + { + clipId: "clip-a", + sourceTrackId: "track-a", + targetTrackId: "track-a", + originalStartTime: 1, + newStartTime: 3, + duration: 1, + }, + ], source); + + const points = copied[0].automationLanes[0].points; + expect(points.map((point: AutomationPoint) => point.time)).toEqual([1.5, 2.5, 3.5, 5]); + const copiedIds = points.filter((point: AutomationPoint) => point.time === 2.5 || point.time === 3.5) + .map((point: AutomationPoint) => point.id); + expect(new Set(copiedIds).size).toBe(2); + expect(copiedIds).not.toContain("source"); + }); + + it("duplicates and repeats item automation atomically with stable redo identities", () => { + useDAWStore.setState({ + tracks: [track("track-a", [clip("clip-a", 1, 1)], [ + { id: "source", time: 1.5, value: 0.5 }, + { id: "destination-old", time: 2.5, value: 0.1 }, + { id: "outside", time: 5, value: 0.8 }, + ])], + selectedClipId: "clip-a", + selectedClipIds: ["clip-a"], + moveEnvelopesWithItems: true, + }); + + const duplicateIds = useDAWStore.getState().duplicateSelectedClips(); + expect(duplicateIds).toHaveLength(1); + let duplicatedPoints = useDAWStore.getState().tracks[0].automationLanes[0].points; + expect(duplicatedPoints.map((point) => point.time)).toEqual([1.5, 2.5, 5]); + const duplicatedPointId = duplicatedPoints.find((point) => point.time === 2.5)!.id; + expect(duplicatedPointId).not.toBe("source"); + expect(duplicatedPointId).not.toBe("destination-old"); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(pointTimes("track-a")).toEqual([ + ["source", 1.5], + ["destination-old", 2.5], + ["outside", 5], + ]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points + .find((point) => point.time === 2.5)?.id).toBe(duplicatedPointId); + + useDAWStore.getState().undo(); + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + useDAWStore.getState().repeatClip("clip-a", 2); + const repeatedPoints = useDAWStore.getState().tracks[0].automationLanes[0].points; + expect(repeatedPoints.map((point) => point.time)).toEqual([1.5, 2.5, 3.5, 5]); + const repeatedIds = repeatedPoints + .filter((point) => point.time === 2.5 || point.time === 3.5) + .map((point) => point.id); + expect(new Set(repeatedIds).size).toBe(2); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points + .filter((point) => point.time === 2.5 || point.time === 3.5) + .map((point) => point.id)).toEqual(repeatedIds); + }); + + it("keeps deleted-item automation timeline-fixed and ripple-moves only surviving item ranges", () => { + const locked = { ...clip("locked", 5, 1), locked: true }; + useDAWStore.setState({ + tracks: [track("track-a", [clip("delete", 1, 1), clip("move", 3, 1), locked], [ + { id: "outside", time: 0.5, value: 0.1 }, + { id: "deleted", time: 1.5, value: 0.2 }, + { id: "moving", time: 3.5, value: 0.3 }, + { id: "locked-point", time: 5.5, value: 0.4 }, + ])], + selectedClipId: "delete", + selectedClipIds: ["delete"], + moveEnvelopesWithItems: true, + rippleMode: "per_track", + }); + + expect(useDAWStore.getState().deleteSelectedClips()).toBe(true); + expect(useDAWStore.getState().tracks[0].clips.map((item) => [item.id, item.startTime])) + .toEqual([["move", 2], ["locked", 5]]); + expect(pointTimes("track-a")).toEqual([ + ["outside", 0.5], + ["deleted", 1.5], + ["moving", 2.5], + ["locked-point", 5.5], + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips.map((item) => [item.id, item.startTime])) + .toEqual([["delete", 1], ["move", 3], ["locked", 5]]); + expect(pointTimes("track-a")).toEqual([ + ["outside", 0.5], + ["deleted", 1.5], + ["moving", 3.5], + ["locked-point", 5.5], + ]); + useDAWStore.getState().redo(); + expect(pointTimes("track-a")).toEqual([ + ["outside", 0.5], + ["deleted", 1.5], + ["moving", 2.5], + ["locked-point", 5.5], + ]); + }); + + it("moves only points in the original item interval and preserves stable IDs", () => { + const source = [track("track-a", [clip("clip-a", 1)], [ + { id: "before", time: 0.5, value: 0.1 }, + { id: "start", time: 1, value: 0.2 }, + { id: "middle", time: 2, value: 0.3 }, + { id: "end", time: 3, value: 0.4 }, + { id: "after", time: 3.5, value: 0.5 }, + ])]; + const current = cloneTracks(source); + current[0].clips[0].startTime = 4; + + const result = moveAutomationPointsWithClips(current, [{ + clipId: "clip-a", + sourceTrackId: "track-a", + targetTrackId: "track-a", + originalStartTime: 1, + newStartTime: 4, + duration: 2, + }], source); + + expect(result[0].automationLanes[0].points.map((point: AutomationPoint) => [point.id, point.time])).toEqual([ + ["before", 0.5], + ["after", 3.5], + ["start", 4], + ["middle", 5], + ["end", 6], + ]); + }); + + it("moves multiple item ranges across tracks without moving a point twice", () => { + const source = [ + track("track-a", [clip("clip-a", 1, 1), clip("clip-b", 4, 1)], [ + { id: "outside-a", time: 0.5, value: 0.1 }, + { id: "a", time: 1.25, value: 0.2 }, + { id: "a-end", time: 2, value: 0.3 }, + { id: "between", time: 2.5, value: 0.4 }, + { id: "b", time: 4.5, value: 0.5 }, + { id: "b-end", time: 5, value: 0.6 }, + { id: "outside-b", time: 6, value: 0.7 }, + ]), + track("track-b", [], [{ id: "target-existing", time: 1, value: 0.8 }]), + ]; + const current = cloneTracks(source); + current[0].clips = []; + current[1].clips = [clip("clip-a", 10, 1), clip("clip-b", 20, 1)]; + const moves: AutomationClipMove[] = [ + { + clipId: "clip-a", + sourceTrackId: "track-a", + targetTrackId: "track-b", + originalStartTime: 1, + newStartTime: 10, + duration: 1, + }, + { + clipId: "clip-b", + sourceTrackId: "track-a", + targetTrackId: "track-b", + originalStartTime: 4, + newStartTime: 20, + duration: 1, + }, + ]; + + const result = moveAutomationPointsWithClips(current, moves, source); + + expect(result[0].automationLanes[0].points.map((point: AutomationPoint) => [point.id, point.time])).toEqual([ + ["outside-a", 0.5], + ["between", 2.5], + ["outside-b", 6], + ]); + expect(result[1].automationLanes[0].points.map((point: AutomationPoint) => [point.id, point.time])).toEqual([ + ["target-existing", 1], + ["a", 10.25], + ["a-end", 11], + ["b", 20.5], + ["b-end", 21], + ]); + expect(result[1].automationLanes[0].id).toBe("track-b-volume"); + }); + + it("creates a matching target lane on a cross-track move", () => { + const source = [ + track("track-a", [clip("clip-a", 1)], [{ id: "moving", time: 2, value: 0.4 }]), + track("track-b", [], [], false), + ]; + const current = cloneTracks(source); + current[0].clips = []; + current[1].clips = [clip("clip-a", 5)]; + + const result = moveAutomationPointsWithClips(current, [{ + clipId: "clip-a", + sourceTrackId: "track-a", + targetTrackId: "track-b", + originalStartTime: 1, + newStartTime: 5, + duration: 2, + }], source); + + expect(result[0].automationLanes[0].points).toEqual([]); + expect(result[1].automationLanes).toHaveLength(1); + expect(result[1].automationLanes[0]).toMatchObject({ + id: "lane_volume_track-b", + param: "volume", + points: [{ id: "moving", time: 6, value: 0.4 }], + }); + }); + + it("returns the original collection for invalid or non-intersecting moves", () => { + const source = [track("track-a", [clip("clip-a", 1)], [ + { id: "outside", time: 10, value: 0.4 }, + ])]; + expect(moveAutomationPointsWithClips(source, [], source)).toBe(source); + expect(moveAutomationPointsWithClips(source, [{ + clipId: "clip-a", + sourceTrackId: "track-a", + targetTrackId: "track-a", + originalStartTime: Number.NaN, + newStartTime: 4, + duration: 2, + }], source)).toBe(source); + expect(moveAutomationPointsWithClips(source, [{ + clipId: "clip-a", + sourceTrackId: "track-a", + targetTrackId: "track-a", + originalStartTime: 1, + newStartTime: 4, + duration: 2, + }], source)).toBe(source); + }); + + it.each([ + { preference: true, locked: false, follows: true }, + { preference: false, locked: false, follows: false }, + { preference: true, locked: true, follows: false }, + ])("nudges clip and automation atomically when follows=$follows", ({ preference, locked, follows }) => { + useDAWStore.setState({ + tracks: [track("track-a", [clip("clip-a", 1)], [ + { id: "inside", time: 2, value: 0.5 }, + { id: "outside", time: 4, value: 0.8 }, + ])], + selectedClipId: "clip-a", + selectedClipIds: ["clip-a"], + moveEnvelopesWithItems: preference, + lockSettings: { + ...useDAWStore.getState().lockSettings, + envelopes: locked, + }, + }); + + useDAWStore.getState().nudgeClips("right", true); + + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(1.01); + expect(pointTimes("track-a")).toEqual([ + ["inside", follows ? 2.01 : 2], + ["outside", 4], + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(1); + expect(pointTimes("track-a")).toEqual([ + ["inside", 2], + ["outside", 4], + ]); + expect(commandManager.getRedoStack()).toHaveLength(1); + }); + + it("nudges multiple selected clips and their points in one undo command", () => { + useDAWStore.setState({ + tracks: [track("track-a", [clip("clip-a", 1, 1), clip("clip-b", 4, 1)], [ + { id: "a", time: 1.5, value: 0.3 }, + { id: "b", time: 4.5, value: 0.7 }, + ])], + selectedClipId: "clip-a", + selectedClipIds: ["clip-a", "clip-b"], + moveEnvelopesWithItems: true, + }); + + useDAWStore.getState().nudgeClips("right", true); + + expect(useDAWStore.getState().tracks[0].clips.map((item) => item.startTime)).toEqual([1.01, 4.01]); + expect(pointTimes("track-a")).toEqual([ + ["a", 1.51], + ["b", 4.51], + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips.map((item) => item.startTime)).toEqual([1, 4]); + expect(pointTimes("track-a")).toEqual([ + ["a", 1.5], + ["b", 4.5], + ]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips.map((item) => item.startTime)).toEqual([1.01, 4.01]); + expect(pointTimes("track-a")).toEqual([ + ["a", 1.51], + ["b", 4.51], + ]); + }); + + it("moves a clip and its interval automation across tracks in one undo command", async () => { + useDAWStore.setState({ + tracks: [ + track("track-a", [clip("clip-a", 1)], [ + { id: "outside-before", time: 0.5, value: 0.1 }, + { id: "inside", time: 2, value: 0.5 }, + { id: "outside-after", time: 4, value: 0.9 }, + ]), + track("track-b", [], [{ id: "existing", time: 1, value: 0.4 }]), + ], + moveEnvelopesWithItems: true, + }); + + await useDAWStore.getState().moveClipToTrack("clip-a", "track-b", 5); + + expect(useDAWStore.getState().tracks[0].clips).toEqual([]); + expect(useDAWStore.getState().tracks[1].clips[0]).toMatchObject({ id: "clip-a", startTime: 5 }); + expect(pointTimes("track-a")).toEqual([ + ["outside-before", 0.5], + ["outside-after", 4], + ]); + expect(pointTimes("track-b")).toEqual([ + ["existing", 1], + ["inside", 6], + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ id: "clip-a", startTime: 1 }); + expect(useDAWStore.getState().tracks[1].clips).toEqual([]); + expect(pointTimes("track-a")).toEqual([ + ["outside-before", 0.5], + ["inside", 2], + ["outside-after", 4], + ]); + expect(pointTimes("track-b")).toEqual([["existing", 1]]); + + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips).toEqual([]); + expect(useDAWStore.getState().tracks[1].clips[0]).toMatchObject({ id: "clip-a", startTime: 5 }); + expect(pointTimes("track-a")).toEqual([ + ["outside-before", 0.5], + ["outside-after", 4], + ]); + expect(pointTimes("track-b")).toEqual([ + ["existing", 1], + ["inside", 6], + ]); + expect(useDAWStore.getState().tracks[1].automationLanes[0].id).toBe("track-b-volume"); + }); + + it("does not create history for missing targets or unchanged clip positions", async () => { + useDAWStore.setState({ + tracks: [track("track-a", [clip("clip-a", 1)], [ + { id: "inside", time: 2, value: 0.5 }, + ])], + moveEnvelopesWithItems: true, + }); + + await useDAWStore.getState().moveClipToTrack("missing", "track-a", 4); + await useDAWStore.getState().moveClipToTrack("clip-a", "missing-track", 4); + await useDAWStore.getState().moveClipToTrack("clip-a", "track-a", 1); + + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(1); + expect(pointTimes("track-a")).toEqual([["inside", 2]]); + }); +}); + +describe("Cubase momentary automation-follow inversion", () => { + it("XORs physical Shift with the persisted preference and never bypasses envelope lock", () => { + expect(shouldInvertAutomationFollowForClipDrag("cubase", true, true)).toBe(true); + expect(shouldInvertAutomationFollowForClipDrag("cubase", false, true)).toBe(false); + expect(shouldInvertAutomationFollowForClipDrag("reaper", true, true)).toBe(false); + expect(shouldInvertAutomationFollowForClipDrag("cubase", true, false)).toBe(false); + + expect(shouldMoveAutomationWithItems(true, false, false)).toBe(true); + expect(shouldMoveAutomationWithItems(true, true, false)).toBe(false); + expect(shouldMoveAutomationWithItems(false, false, false)).toBe(false); + expect(shouldMoveAutomationWithItems(false, true, false)).toBe(true); + expect(shouldMoveAutomationWithItems(false, true, true)).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/automationPointDrag.test.ts b/frontend/src/__tests__/automationPointDrag.test.ts new file mode 100644 index 0000000..9cfc712 --- /dev/null +++ b/frontend/src/__tests__/automationPointDrag.test.ts @@ -0,0 +1,414 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AutomationPoint, + useDAWStore, +} from "../store/useDAWStore"; +import { + resolveAutomationPointDrag, + type AutomationPointDragGesture, +} from "../utils/automationPointDrag"; +import { snapTimeByType } from "../utils/snapToGrid"; + +const originalState = useDAWStore.getState(); + +const gesture = ( + action: AutomationPointDragGesture["action"], + overrides: Partial = {}, +): AutomationPointDragGesture => ({ + action, + originalX: 125, + originalY: 50, + originalTime: 1.25, + originalValue: 0.5, + axisLock: null, + ...overrides, +}); + +const resolve = ( + action: AutomationPointDragGesture["action"], + overrides: Partial[1]> = {}, + gestureOverrides: Partial = {}, +) => resolveAutomationPointDrag(gesture(action, gestureOverrides), { + rawX: 137, + rawY: 20, + scrollX: 0, + pixelsPerSecond: 100, + laneTop: 0, + laneHeight: 100, + snapEnabled: false, + snapTime: (time) => time, + ...overrides, +}); + +describe("automation point drag geometry", () => { + it("snaps ordinary time movement through the active Timeline grid resolver", () => { + const snapTime = vi.fn((time: number, originalTime: number) => snapTimeByType({ + time, + originalTime, + tempo: 120, + timeSignature: { numerator: 4, denominator: 4 }, + gridSize: "beat", + snapType: "grid", + })); + + const result = resolve("move", { snapEnabled: true, snapTime }); + + expect(snapTime).toHaveBeenCalledOnce(); + expect(snapTime).toHaveBeenCalledWith(1.37, 1.25); + expect(result).toMatchObject({ + time: 1.5, + value: 0.8, + x: 150, + snapApplied: true, + timeLocked: false, + valueLocked: false, + }); + expect(result.y).toBeCloseTo(20); + }); + + it("preserves raw time for the explicit snap-bypass action", () => { + const snapTime = vi.fn(() => 99); + + const result = resolve("bypass_snap", { snapEnabled: true, snapTime }); + + expect(snapTime).not.toHaveBeenCalled(); + expect(result.time).toBeCloseTo(1.37); + expect(result.x).toBeCloseTo(137); + expect(result.value).toBeCloseTo(0.8); + expect(result.snapApplied).toBe(false); + }); + + it("defines constrain_x as time-only movement and constrain_y as value-only movement", () => { + const snapTime = vi.fn(() => 1.5); + const horizontal = resolve("constrain_x", { snapEnabled: true, snapTime }); + expect(horizontal).toMatchObject({ + time: 1.5, + value: 0.5, + x: 150, + y: 50, + timeLocked: false, + valueLocked: true, + snapApplied: true, + }); + + snapTime.mockClear(); + const vertical = resolve("constrain_y", { snapEnabled: true, snapTime }); + expect(vertical).toMatchObject({ + time: 1.25, + value: 0.8, + x: 125, + timeLocked: true, + valueLocked: false, + snapApplied: false, + }); + expect(vertical.y).toBeCloseTo(20); + expect(snapTime).not.toHaveBeenCalled(); + }); + + it("preserves both meanings for fine/axis plus snap-bypass compounds", () => { + const snapTime = vi.fn(() => 99); + const horizontal = resolve("constrain_x_bypass_snap", { + snapEnabled: true, + snapTime, + }); + expect(horizontal.time).toBeCloseTo(1.37); + expect(horizontal.value).toBe(0.5); + expect(horizontal).toMatchObject({ valueLocked: true, snapApplied: false }); + + const vertical = resolve("constrain_y_bypass_snap", { + snapEnabled: true, + snapTime, + }); + expect(vertical.time).toBe(1.25); + expect(vertical.value).toBeCloseTo(0.8); + expect(vertical).toMatchObject({ timeLocked: true, snapApplied: false }); + + const fine = resolve("fine_bypass_snap", { + rawX: 225, + rawY: -50, + snapEnabled: true, + snapTime, + }); + expect(fine.time).toBeCloseTo(1.35); + expect(fine.value).toBeCloseTo(0.6); + expect(fine.snapApplied).toBe(false); + + const dominant = resolve("constrain_axis_bypass_snap", { + rawX: 137, + rawY: 47, + snapEnabled: true, + snapTime, + }); + expect(dominant).toMatchObject({ + axisLock: "time", + value: 0.5, + valueLocked: true, + snapApplied: false, + }); + expect(dominant.time).toBeCloseTo(1.37); + expect(snapTime).not.toHaveBeenCalled(); + }); + + it("treats Reason copy movement like ordinary movement and preserves its axis constraint", () => { + const copied = resolve("copy", { + snapEnabled: true, + snapTime: () => 1.5, + }); + expect(copied).toMatchObject({ + time: 1.5, + value: 0.8, + snapApplied: true, + timeLocked: false, + valueLocked: false, + }); + + const constrainedCopy = resolve("copy_constrain_axis", { + rawX: 140, + rawY: 47, + snapEnabled: false, + }); + expect(constrainedCopy).toMatchObject({ + axisLock: "time", + value: 0.5, + valueLocked: true, + }); + expect(constrainedCopy.time).toBeCloseTo(1.4); + }); + + it("chooses and then preserves the dominant axis after the movement threshold", () => { + const undecided = resolve("constrain_axis", { + rawX: 126, + rawY: 49, + }); + expect(undecided.axisLock).toBeNull(); + expect(undecided.valueLocked).toBe(false); + expect(undecided.timeLocked).toBe(false); + + const horizontal = resolve("constrain_axis", { + rawX: 130, + rawY: 47, + }); + expect(horizontal).toMatchObject({ axisLock: "time", valueLocked: true }); + expect(horizontal.value).toBe(0.5); + + const stillHorizontal = resolve( + "constrain_axis", + { rawX: 126, rawY: 5 }, + { axisLock: horizontal.axisLock }, + ); + expect(stillHorizontal).toMatchObject({ axisLock: "time", valueLocked: true }); + expect(stillHorizontal.value).toBe(0.5); + + const vertical = resolve("constrain_axis", { + rawX: 127, + rawY: 40, + }); + expect(vertical).toMatchObject({ + axisLock: "value", + timeLocked: true, + time: 1.25, + }); + }); + + it("applies fine movement before snapping and clamps time/value safely", () => { + const fine = resolve("fine", { + rawX: 225, + rawY: -50, + snapEnabled: false, + }); + expect(fine.time).toBeCloseTo(1.35); + expect(fine.value).toBeCloseTo(0.6); + + const clamped = resolve("bypass_snap", { + rawX: -1_000, + rawY: 1_000, + scrollX: 50, + }); + expect(clamped).toMatchObject({ time: 0, value: 0, x: -50, y: 100 }); + }); +}); + +describe("automation point drag transaction", () => { + const point = (id: string, time: number, value: number): AutomationPoint => ({ + id, + time, + value, + }); + + beforeEach(() => { + useDAWStore.getState().cancelAutomationPointEdit(); + commandManager.clear(); + vi.spyOn(nativeBridge, "setAutomationPoints").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [{ + ...createDefaultTrack("track-a", "Track A", "#fff", "audio", []), + showAutomation: true, + automationLanes: [{ + id: "track-volume", + param: "volume", + points: [point("stable-point", 1.25, 0.5)], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }], + }], + masterAutomationLanes: [], + selectedAutomationTarget: null, + lockSettings: { + ...useDAWStore.getState().lockSettings, + envelopes: false, + }, + isModified: false, + canUndo: false, + canRedo: false, + }); + }); + + afterEach(() => { + useDAWStore.getState().cancelAutomationPointEdit(); + commandManager.clear(); + vi.restoreAllMocks(); + useDAWStore.setState(originalState); + }); + + const target = { + kind: "track" as const, + trackId: "track-a", + laneId: "track-volume", + pointId: "stable-point", + }; + + const currentPoint = () => useDAWStore.getState().tracks[0].automationLanes[0].points[0]; + + it("commits a snapped preview with its stable ID as exactly one undo command", () => { + const preview = resolve("move", { + snapEnabled: true, + snapTime: () => 1.5, + }); + + expect(useDAWStore.getState().beginAutomationPointEdit(target)).toBe(true); + expect(useDAWStore.getState().previewAutomationPointEdit(preview.time, preview.value)).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().commitAutomationPointEdit()).toBe(true); + expect(currentPoint()).toEqual(point("stable-point", 1.5, 0.8)); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(currentPoint()).toEqual(point("stable-point", 1.25, 0.5)); + }); + + it("restores cancellation exactly and creates no history for cancellation or a no-op drag", () => { + expect(useDAWStore.getState().beginAutomationPointEdit(target)).toBe(true); + expect(useDAWStore.getState().previewAutomationPointEdit(2, 0.9)).toBe(true); + expect(useDAWStore.getState().cancelAutomationPointEdit()).toBe(true); + expect(currentPoint()).toEqual(point("stable-point", 1.25, 0.5)); + expect(useDAWStore.getState().isModified).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + + expect(useDAWStore.getState().beginAutomationPointEdit(target)).toBe(true); + expect(useDAWStore.getState().previewAutomationPointEdit(1.25, 0.5)).toBe(true); + expect(useDAWStore.getState().commitAutomationPointEdit()).toBe(false); + expect(currentPoint()).toEqual(point("stable-point", 1.25, 0.5)); + expect(useDAWStore.getState().isModified).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("copy-drags a stable point while preserving a new stable-ID source copy in one command", () => { + expect(useDAWStore.getState().beginAutomationPointCopyEdit(target)).toBe(true); + let points = useDAWStore.getState().tracks[0].automationLanes[0].points; + expect(points).toHaveLength(2); + const preservedCopy = points.find((candidate) => candidate.id !== "stable-point"); + expect(preservedCopy).toMatchObject({ time: 1.25, value: 0.5 }); + expect(preservedCopy?.id).toBeTypeOf("string"); + + expect(useDAWStore.getState().previewAutomationPointEdit(2, 0.75)).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().commitAutomationPointEdit()).toBe(true); + + points = useDAWStore.getState().tracks[0].automationLanes[0].points; + expect(points).toHaveLength(2); + expect(points.find((candidate) => candidate.id === "stable-point")).toEqual( + point("stable-point", 2, 0.75), + ); + expect(points.find((candidate) => candidate.id === preservedCopy?.id)).toEqual(preservedCopy); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points).toEqual([ + point("stable-point", 1.25, 0.5), + ]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points).toEqual(points); + }); + + it("removes a provisional source copy exactly on cancel and on snapped no-op commit", () => { + expect(useDAWStore.getState().beginAutomationPointCopyEdit(target)).toBe(true); + expect(useDAWStore.getState().previewAutomationPointEdit(2, 0.75)).toBe(true); + expect(useDAWStore.getState().cancelAutomationPointEdit()).toBe(true); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points).toEqual([ + point("stable-point", 1.25, 0.5), + ]); + expect(useDAWStore.getState().isModified).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + + expect(useDAWStore.getState().beginAutomationPointCopyEdit(target)).toBe(true); + expect(useDAWStore.getState().previewAutomationPointEdit(1.25, 0.5)).toBe(true); + expect(useDAWStore.getState().commitAutomationPointEdit()).toBe(false); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points).toEqual([ + point("stable-point", 1.25, 0.5), + ]); + expect(useDAWStore.getState().isModified).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("uses the same atomic copy transaction for a master automation point", () => { + useDAWStore.setState({ + masterAutomationLanes: [{ + id: "master-volume", + param: "volume", + points: [point("master-stable", 1, 0.25)], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }], + }); + const masterTarget = { + kind: "master" as const, + laneId: "master-volume", + pointId: "master-stable", + }; + + expect(useDAWStore.getState().beginAutomationPointCopyEdit(masterTarget)).toBe(true); + expect(useDAWStore.getState().previewAutomationPointEdit(2, 0.75)).toBe(true); + expect(useDAWStore.getState().commitAutomationPointEdit()).toBe(true); + expect(useDAWStore.getState().masterAutomationLanes[0].points).toHaveLength(2); + expect(useDAWStore.getState().masterAutomationLanes[0].points).toContainEqual( + point("master-stable", 2, 0.75), + ); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().masterAutomationLanes[0].points).toEqual([ + point("master-stable", 1, 0.25), + ]); + }); + + it("rejects a transaction while envelope locking is enabled", () => { + useDAWStore.setState({ + lockSettings: { + ...useDAWStore.getState().lockSettings, + envelopes: true, + }, + }); + + expect(useDAWStore.getState().beginAutomationPointEdit(target)).toBe(false); + expect(useDAWStore.getState().beginAutomationPointCopyEdit(target)).toBe(false); + expect(currentPoint()).toEqual(point("stable-point", 1.25, 0.5)); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/automationProfileBindings.test.ts b/frontend/src/__tests__/automationProfileBindings.test.ts new file mode 100644 index 0000000..e8fa5bd --- /dev/null +++ b/frontend/src/__tests__/automationProfileBindings.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { getActionShortcutScopes, getRegisteredAction } from "../store/actionRegistry"; +import { + KEYBOARD_SHORTCUT_PROFILES, + getProfileActionBindings, +} from "../utils/shortcutProfiles"; + +describe("source-DAW automation shortcut profiles", () => { + it.each(["logic_pro", "garageband", "ableton_live"] as const)( + "maps %s A to the implemented arrangement automation view", + (profileId) => { + expect(getProfileActionBindings( + profileId, + "automation.toggleArrangementView", + "macos", + )).toEqual(["A"]); + }, + ); + + it("does not leak OpenStudio's arrangement A into other vendor profiles", () => { + const verifiedArrangementProfiles = new Set(["logic_pro", "garageband", "ableton_live"]); + for (const profile of KEYBOARD_SHORTCUT_PROFILES) { + if (profile.id === "openstudio" || verifiedArrangementProfiles.has(profile.id)) continue; + expect( + getProfileActionBindings(profile.id, "automation.toggleArrangementView", "macos"), + `${profile.id} must explicitly own or unbind arrangement automation`, + ).toEqual([]); + } + }); + + it("maps Ableton's point and envelope navigation without changing modifier meaning", () => { + expect(getProfileActionBindings("ableton_live", "automation.point.selectNext", "macos")) + .toEqual(["Tab", "Option+Right"]); + expect(getProfileActionBindings("ableton_live", "automation.point.selectNext", "windows")) + .toEqual(["Tab", "Alt+Right"]); + expect(getProfileActionBindings("ableton_live", "automation.point.selectPrevious", "macos")) + .toEqual(["Shift+Tab", "Option+Left"]); + expect(getProfileActionBindings("ableton_live", "automation.point.deleteSelected", "windows")) + .toEqual(["Delete"]); + expect(getProfileActionBindings("ableton_live", "automation.point.addAtPlayhead", "macos")) + .toEqual(["Enter"]); + expect(getProfileActionBindings("ableton_live", "automation.lane.selectPrevious", "macos")) + .toEqual(["Option+Up"]); + expect(getProfileActionBindings("ableton_live", "automation.lane.selectNext", "windows")) + .toEqual(["Alt+Down"]); + }); + + it("maps only exact implemented Studio One automation commands", () => { + expect(getProfileActionBindings("studio_one", "track.toggleSelectedAutomation", "windows")) + .toEqual(["A"]); + expect(getProfileActionBindings("studio_one", "track.toggleSelectedAutomationRead", "windows")) + .toEqual(["J"]); + expect(getProfileActionBindings("studio_one", "automation.selectedTracks.mode.touch", "windows")) + .toEqual(["K"]); + expect(getProfileActionBindings("studio_one", "automation.toggleArrangementView", "windows")) + .toEqual([]); + expect(getActionShortcutScopes( + getRegisteredAction("track.toggleSelectedAutomation")!, + "studio_one", + )).toContain("timeline"); + }); + + it("maps Logic's selected-track and all-track mode commands without changing scope", () => { + expect(getProfileActionBindings( + "logic_pro", + "automation.selectedTracks.toggleOffRead", + "macos", + )).toEqual(["Control+Command+O"]); + expect(getProfileActionBindings( + "logic_pro", + "automation.selectedTracks.toggleLatchRead", + "windows", + )).toEqual(["Control+Meta+A"]); + + const allTrackModes = { + off: "O", + read: "R", + touch: "T", + latch: "L", + } as const; + for (const [mode, key] of Object.entries(allTrackModes)) { + expect(getProfileActionBindings( + "logic_pro", + `automation.allTracks.mode.${mode}`, + "macos", + )).toEqual([`Control+Command+Shift+${key}`]); + expect(getProfileActionBindings( + "logic_pro", + `automation.allTracks.mode.${mode}`, + "windows", + )).toEqual([`Control+Meta+Shift+${key}`]); + } + }); + + it("maps Cakewalk's global write-off and read-toggle commands exactly", () => { + expect(getProfileActionBindings( + "cakewalk_sonar", + "automation.allTracks.writeOff", + "windows", + )).toEqual(["F12"]); + expect(getProfileActionBindings( + "cakewalk_sonar", + "automation.allTracks.toggleRead", + "windows", + )).toEqual(["Ctrl+F12"]); + // Profiles remain portable even when their source DAW is platform-specific. + expect(getProfileActionBindings( + "cakewalk_sonar", + "automation.allTracks.toggleRead", + "macos", + )).toEqual(["Ctrl+F12"]); + }); + + it("keeps every mapped automation identity backed by an executable registry action", () => { + for (const actionId of [ + "automation.toggleArrangementView", + "automation.point.selectNext", + "automation.point.selectPrevious", + "automation.point.deleteSelected", + "automation.point.addAtPlayhead", + "automation.lane.selectPrevious", + "automation.lane.selectNext", + "track.toggleSelectedAutomation", + "track.toggleSelectedAutomationRead", + "automation.selectedTracks.mode.touch", + "automation.selectedTracks.toggleOffRead", + "automation.selectedTracks.toggleLatchRead", + "automation.allTracks.mode.off", + "automation.allTracks.mode.read", + "automation.allTracks.mode.touch", + "automation.allTracks.mode.latch", + "automation.allTracks.writeOff", + "automation.allTracks.toggleRead", + ]) { + expect(getRegisteredAction(actionId), actionId).toBeDefined(); + } + }); +}); diff --git a/frontend/src/__tests__/automationShortcutContextIntegration.test.ts b/frontend/src/__tests__/automationShortcutContextIntegration.test.ts new file mode 100644 index 0000000..eec0b8c --- /dev/null +++ b/frontend/src/__tests__/automationShortcutContextIntegration.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type AutomationLane, + useDAWStore, +} from "../store/useDAWStore"; +import { activateAutomationLaneShortcutContext } from "../utils/automationShortcutContext"; +import { dispatchGlobalShortcut } from "../utils/globalShortcutDispatcher"; +import { + getActiveShortcutContext, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; + +const originalState = useDAWStore.getState(); + +function clip(): AudioClip { + return { + id: "selected-clip", + name: "Selected clip", + filePath: "C:/audio/selected.wav", + startTime: 0, + duration: 4, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }; +} + +function emptyLane(id: string): AutomationLane { + return { + id, + param: "volume", + points: [], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }; +} + +beforeEach(() => { + commandManager.clear(); + resetShortcutContextForTests(); + vi.spyOn(nativeBridge, "setAutomationPoints").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setAutomationMode").mockResolvedValue(true); + vi.spyOn(nativeBridge, "clearAutomation").mockResolvedValue(true); + const automationLane = emptyLane("track-volume"); + const track = { + ...createDefaultTrack("track-a", "Track A", "#38bdf8", "audio", []), + clips: [clip()], + automationLanes: [automationLane], + showAutomation: true, + automationReadEnabled: true, + automationEnabled: true, + }; + useDAWStore.setState({ + tracks: [track], + selectedTrackId: "track-a", + selectedTrackIds: ["track-a"], + selectedClipId: "selected-clip", + selectedClipIds: ["selected-clip"], + selectedAutomationTarget: null, + masterAutomationLanes: [emptyLane("master-volume")], + showMasterAutomation: true, + masterAutomationReadEnabled: true, + masterAutomationEnabled: true, + customShortcuts: { + "automation.point.addAtPlayhead": "Ctrl+Shift+P", + }, + lockSettings: { + ...useDAWStore.getState().lockSettings, + envelopes: false, + }, + transport: { + ...useDAWStore.getState().transport, + currentTime: 2, + isPlaying: false, + }, + canUndo: false, + canRedo: false, + }); +}); + +afterEach(() => { + commandManager.clear(); + resetShortcutContextForTests(); + vi.restoreAllMocks(); + useDAWStore.setState(originalState); +}); + +describe("automation lane focus and shortcut precedence", () => { + it("runs a custom automation chord after an empty track-lane click and keeps Delete off the selected clip", () => { + expect(activateAutomationLaneShortcutContext({ + kind: "track", + trackId: "track-a", + laneId: "track-volume", + })).toBe(true); + expect(getActiveShortcutContext()).toEqual({ kind: "automation" }); + + expect(dispatchGlobalShortcut({ + key: "p", + ctrlKey: true, + shiftKey: true, + source: "browser", + }, "windows")).toBe(true); + + let state = useDAWStore.getState(); + expect(state.tracks[0].automationLanes[0].points).toHaveLength(1); + expect(state.tracks[0].automationLanes[0].points[0].time).toBe(2); + expect(state.selectedAutomationTarget).toMatchObject({ + kind: "track", + trackId: "track-a", + laneId: "track-volume", + }); + expect(state.selectedAutomationTarget?.pointId).toEqual(expect.any(String)); + expect(commandManager.getUndoStack()).toHaveLength(1); + + expect(dispatchGlobalShortcut({ key: "Delete", source: "browser" }, "windows")).toBe(true); + state = useDAWStore.getState(); + expect(state.tracks[0].automationLanes[0].points).toEqual([]); + expect(state.tracks[0].clips.map((candidate) => candidate.id)).toEqual(["selected-clip"]); + expect(state.selectedClipIds).toEqual(["selected-clip"]); + expect(commandManager.getUndoStack()).toHaveLength(2); + + state.undo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].clips).toHaveLength(1); + }); + + it("provides the same empty-lane custom shortcut and Delete behavior for master", () => { + expect(activateAutomationLaneShortcutContext({ + kind: "master", + laneId: "master-volume", + })).toBe(true); + + expect(dispatchGlobalShortcut({ + key: "p", + ctrlKey: true, + shiftKey: true, + source: "browser", + }, "windows")).toBe(true); + let state = useDAWStore.getState(); + expect(state.masterAutomationLanes[0].points).toHaveLength(1); + expect(state.selectedAutomationTarget).toMatchObject({ + kind: "master", + laneId: "master-volume", + }); + + expect(dispatchGlobalShortcut({ key: "Backspace", source: "browser" }, "windows")).toBe(true); + state = useDAWStore.getState(); + expect(state.masterAutomationLanes[0].points).toEqual([]); + expect(state.tracks[0].clips).toHaveLength(1); + expect(commandManager.getUndoStack()).toHaveLength(2); + }); + + it("does not activate automation for a stale lane target", () => { + expect(activateAutomationLaneShortcutContext({ + kind: "track", + trackId: "missing-track", + laneId: "missing-lane", + })).toBe(false); + expect(getActiveShortcutContext()).toEqual({ kind: "application" }); + expect(useDAWStore.getState().selectedAutomationTarget).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/browserWheelGuard.test.ts b/frontend/src/__tests__/browserWheelGuard.test.ts new file mode 100644 index 0000000..e4212a5 --- /dev/null +++ b/frontend/src/__tests__/browserWheelGuard.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { + installBrowserZoomWheelGuard, + resolveBrowserWheelGesture, + shouldSuppressBrowserZoomWheel, +} from "../utils/browserWheelGuard"; + +describe("app-wide browser zoom wheel guard", () => { + it.each([ + { platform: "Windows", event: { ctrlKey: true }, expected: true }, + { platform: "Windows physical Meta", event: { metaKey: true }, expected: true }, + { platform: "macOS Command", event: { metaKey: true }, expected: true }, + { platform: "macOS physical Control / pinch", event: { ctrlKey: true }, expected: true }, + { platform: "macOS both modifiers", event: { ctrlKey: true, metaKey: true }, expected: true }, + { platform: "ordinary scroll", event: {}, expected: false }, + { platform: "ordinary Shift scroll", event: { shiftKey: true }, expected: false }, + ])("returns $expected for $platform", ({ event, expected }) => { + expect(shouldSuppressBrowserZoomWheel(event)).toBe(expected); + }); + + it("prevents browser zoom without stopping child DAW wheel propagation", () => { + expect(resolveBrowserWheelGesture({ deltaY: 3, ctrlKey: true })).toMatchObject({ + operation: "suppress", + preventDefault: true, + stopPropagation: false, + }); + expect(resolveBrowserWheelGesture({ deltaY: 3, metaKey: true })).toMatchObject({ + operation: "suppress", + preventDefault: true, + stopPropagation: false, + }); + }); + + it("preserves ordinary browser/list scrolling", () => { + expect(resolveBrowserWheelGesture({ deltaY: 3 })).toMatchObject({ + operation: "native-scroll", + preventDefault: false, + stopPropagation: false, + }); + }); + + it("prevents at capture without consuming propagation to a child handler", () => { + let listener: EventListener | null = null; + const target = { + addEventListener: vi.fn((_type: string, next: EventListener) => { + listener = next; + }), + removeEventListener: vi.fn(), + } as unknown as Document; + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + const cleanup = installBrowserZoomWheelGuard(target); + + expect(listener).not.toBeNull(); + (listener as unknown as EventListener)({ + ctrlKey: true, + deltaY: 1, + preventDefault, + stopPropagation, + } as unknown as Event); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(stopPropagation).not.toHaveBeenCalled(); + + cleanup(); + expect(target.removeEventListener).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/__tests__/builtInAutomationIdentity.test.ts b/frontend/src/__tests__/builtInAutomationIdentity.test.ts new file mode 100644 index 0000000..b01e45a --- /dev/null +++ b/frontend/src/__tests__/builtInAutomationIdentity.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { builtInAutomationParamId, pluginAutomationParamId } from "../store/automationParams"; + +describe("stable built-in automation ids", () => { + it("uses the schema parameter id instead of a fragile parameter-array index", () => { + expect(builtInAutomationParamId(false, 2, "ampGainDb")) + .toBe("builtin_track_2_ampGainDb"); + expect(builtInAutomationParamId(true, 0, "band0.freq")) + .toBe("builtin_input_0_band0.freq"); + }); + + it("keeps third-party plugin automation ids backward compatible", () => { + expect(pluginAutomationParamId(false, 2, 17)).toBe("plugin_track_2_17"); + }); +}); diff --git a/frontend/src/__tests__/builtInParamValue.test.ts b/frontend/src/__tests__/builtInParamValue.test.ts new file mode 100644 index 0000000..7b18047 --- /dev/null +++ b/frontend/src/__tests__/builtInParamValue.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import type { BuiltInParamDescriptor } from "../services/NativeBridge"; +import { + chorusRateHzFromNormalized, + chorusRateNormalizedFromHz, + migrateLegacyChorusRateAutomationValue, + normalizeParamValue, + quantizeParamValue, +} from "../utils/builtInParamValue"; + +const chorusRateParam: BuiltInParamDescriptor = { + id: "chorusRateHz", + label: "Chorus Rate", + type: "continuous", + value: 1, + min: 0.01, + max: 8, + defaultValue: 0.75, + unit: "Hz", + automatable: true, + graphRole: "modulation", +}; + +describe("NAM Rack chorus rate curve", () => { + it("lands exactly on 0.01, 1, and 8 Hz", () => { + expect(chorusRateHzFromNormalized(0)).toBeCloseTo(0.01, 8); + expect(chorusRateHzFromNormalized(0.5)).toBeCloseTo(1, 8); + expect(chorusRateHzFromNormalized(1)).toBeCloseTo(8, 8); + }); + + it("matches the intended smooth representative values", () => { + expect(chorusRateHzFromNormalized(0.25)).toBeCloseTo(0.1545, 3); + expect(chorusRateHzFromNormalized(0.75)).toBeCloseTo(3.4423, 3); + }); + + it("round-trips raw Hz through the knob position", () => { + for (const rate of [0.01, 0.0438, 0.1545, 0.4382, 1, 1.9487, 3.4423, 5.5118, 8]) { + expect(chorusRateHzFromNormalized(chorusRateNormalizedFromHz(rate))).toBeCloseTo(rate, 5); + } + expect(normalizeParamValue(chorusRateParam, 1)).toBeCloseTo(0.5, 8); + }); + + it("quantizes in knob space so the slow range keeps useful resolution", () => { + const quantized = quantizeParamValue(chorusRateParam, 0.011); + expect(quantized).toBeGreaterThanOrEqual(0.01); + expect(quantized).toBeLessThan(0.02); + }); + + it("migrates legacy linear automation without changing its audible Hz value", () => { + for (const legacyNormalized of [0, 0.25, 0.5, 0.75, 1]) { + const legacyRate = 0.05 + legacyNormalized * 7.95; + const migrated = migrateLegacyChorusRateAutomationValue(legacyNormalized); + expect(chorusRateHzFromNormalized(migrated)).toBeCloseTo(legacyRate, 5); + } + }); +}); diff --git a/frontend/src/__tests__/builtInPluginPanel.test.tsx b/frontend/src/__tests__/builtInPluginPanel.test.tsx index de5dbc6..b664534 100644 --- a/frontend/src/__tests__/builtInPluginPanel.test.tsx +++ b/frontend/src/__tests__/builtInPluginPanel.test.tsx @@ -1,15 +1,17 @@ import { renderToStaticMarkup } from "react-dom/server"; -// @ts-expect-error The app tsconfig does not include Node builtin typings, but Vitest runs this file in Node. -import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { BuiltInPluginPanel, BuiltInParamControl, + createFrameCoalescedParamWriter, + createParamWriteReconciler, + createSchemaRequestGate, formatParamValue, getPluginKind, groupLabel, groupSortWeight, primaryParamIdsForKind, + shouldReadBackAfterParamWrite, stepForParam, } from "../components/BuiltInPluginPanel"; import type { BuiltInParamDescriptor, BuiltInPluginSchema } from "../services/NativeBridge"; @@ -61,6 +63,34 @@ function schemaWithParams(name: string, category: string, params: BuiltInParamDe }; } +function createFrameHarness() { + let nextFrameId = 1; + const callbacks = new Map(); + return { + requestFrame(callback: FrameRequestCallback) { + const frameId = nextFrameId; + nextFrameId += 1; + callbacks.set(frameId, callback); + return frameId; + }, + cancelFrame(frameId: number) { + callbacks.delete(frameId); + }, + runFrame() { + const frameCallbacks = [...callbacks.values()]; + callbacks.clear(); + for (const callback of frameCallbacks) callback(0); + }, + pendingFrames() { + return callbacks.size; + }, + }; +} + +async function flushMicrotasks() { + for (let index = 0; index < 6; index += 1) await Promise.resolve(); +} + function continuous(id: string, label: string, value: number, min = 0, max = 1, graphRole = "controls", unit = "") { return param({ id, label, value, min, max, graphRole, unit }); } @@ -125,7 +155,7 @@ const panelSchemas = [ continuous("rate", "Rate", 1, 0.01, 20, "modulation", "Hz"), continuous("depth", "Depth", 0.5, 0, 1, "modulation"), continuous("mix", "Mix", 0.5, 0, 1, "mix"), - choice("characterMode", "Character", 1, ["Clean", "Ensemble", "BBD"], "character"), + choice("characterMode", "Character", 1, ["Clean", "Ensemble"], "character"), ]), schemaWithParams("S13 Saturator", "Saturation", [ choice("satType", "Type", 1, ["Tape", "Tube", "Console"], "character"), @@ -165,6 +195,271 @@ const panelSchemas = [ ]; describe("BuiltInPluginPanel schema model", () => { + it("coalesces NAM parameter writes only within an animation frame", async () => { + const frames = createFrameHarness(); + const writes: Array<[string, number]> = []; + const writer = createFrameCoalescedParamWriter({ + write: async (paramId, value) => { + writes.push([paramId, value]); + return true; + }, + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + }); + + writer.enqueue("reverbMix", 0.1); + writer.enqueue("reverbMix", 0.2); + writer.enqueue("reverbMix", 0.2); + writer.enqueue("reverbDecaySec", 3.5); + + expect(frames.pendingFrames()).toBe(2); + frames.runFrame(); + await flushMicrotasks(); + + expect(writes).toEqual([ + ["reverbMix", 0.2], + ["reverbDecaySec", 3.5], + ]); + + writer.enqueue("reverbMix", 0.2); + expect(frames.pendingFrames()).toBe(1); + frames.runFrame(); + await flushMicrotasks(); + expect(writes).toEqual([ + ["reverbMix", 0.2], + ["reverbDecaySec", 3.5], + ["reverbMix", 0.2], + ]); + writer.dispose(); + }); + + it("sends the first toggle after a preset changes native state outside the writer", async () => { + const writes: Array<[string, number]> = []; + const writer = createFrameCoalescedParamWriter({ + write: async (paramId, value) => { + writes.push([paramId, value]); + return true; + }, + }); + + writer.writeImmediately("delayEnabled", 0); + await flushMicrotasks(); + + // A preset now enables Delay directly in native state. The editor's next + // off click has the same value as its previous successful write, but it + // must still reach native code because the preset bypassed this writer. + writer.writeImmediately("delayEnabled", 0); + await flushMicrotasks(); + + expect(writes).toEqual([ + ["delayEnabled", 0], + ["delayEnabled", 0], + ]); + writer.dispose(); + }); + + it("retains the trailing knob value while an earlier native write is in flight", async () => { + const frames = createFrameHarness(); + const writes: number[] = []; + let finishFirstWrite: ((ok: boolean) => void) | undefined; + const firstWrite = new Promise((resolve) => { + finishFirstWrite = resolve; + }); + const writer = createFrameCoalescedParamWriter({ + write: async (_paramId, value) => { + writes.push(value); + return writes.length === 1 ? firstWrite : true; + }, + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + }); + + writer.enqueue("chorusDepth", 0.2); + frames.runFrame(); + writer.enqueue("chorusDepth", 0.6); + writer.enqueue("chorusDepth", 0.8); + expect(writes).toEqual([0.2]); + expect(frames.pendingFrames()).toBe(0); + + finishFirstWrite?.(true); + await flushMicrotasks(); + expect(frames.pendingFrames()).toBe(1); + + frames.runFrame(); + await flushMicrotasks(); + expect(writes).toEqual([0.2, 0.8]); + writer.dispose(); + }); + + it("recovers only when a failed write is still the final requested value", async () => { + const frames = createFrameHarness(); + const failures: Array<[string, number]> = []; + let finishFirstWrite: ((ok: boolean) => void) | undefined; + const firstWrite = new Promise((resolve) => { + finishFirstWrite = resolve; + }); + let writeCount = 0; + const writer = createFrameCoalescedParamWriter({ + write: async (_paramId, _value) => { + writeCount += 1; + return writeCount === 1 ? firstWrite : writeCount === 2; + }, + onFailure: (paramId, value) => failures.push([paramId, value]), + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + }); + + writer.enqueue("delayFeedback", 0.3); + frames.runFrame(); + writer.enqueue("delayFeedback", 0.5); + finishFirstWrite?.(false); + await flushMicrotasks(); + expect(failures).toEqual([]); + + frames.runFrame(); + await flushMicrotasks(); + expect(failures).toEqual([]); + + writer.enqueue("delayFeedback", 0.7); + frames.runFrame(); + await flushMicrotasks(); + expect(failures).toEqual([["delayFeedback", 0.7]]); + writer.dispose(); + }); + + it("flushes the final queued value when the panel is disposed before the next frame", async () => { + const frames = createFrameHarness(); + const writes: number[] = []; + const writer = createFrameCoalescedParamWriter({ + write: async (_paramId, value) => { + writes.push(value); + return true; + }, + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + }); + + writer.enqueue("drive", 0.25); + writer.enqueue("drive", 0.75); + writer.dispose(true); + await flushMicrotasks(); + + expect(frames.pendingFrames()).toBe(0); + expect(writes).toEqual([0.75]); + }); + + it("drains queued and in-flight parameter writes before a preset snapshot", async () => { + const frames = createFrameHarness(); + const writes: number[] = []; + let finishFirstWrite: ((ok: boolean) => void) | undefined; + const firstWrite = new Promise((resolve) => { + finishFirstWrite = resolve; + }); + const writer = createFrameCoalescedParamWriter({ + write: async (_paramId, value) => { + writes.push(value); + return writes.length === 1 ? firstWrite : true; + }, + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + }); + + writer.enqueue("reverbDecaySec", 4); + frames.runFrame(); + writer.enqueue("reverbDecaySec", 10); + const flushed = writer.flush(); + + expect(frames.pendingFrames()).toBe(0); + expect(writes).toEqual([4]); + finishFirstWrite?.(true); + await flushMicrotasks(); + + await expect(flushed).resolves.toBe(true); + expect(writes).toEqual([4, 10]); + writer.dispose(); + }); + + it("tracks immediate toggle writes and rejects a preset flush after a final native failure", async () => { + const frames = createFrameHarness(); + const failures: Array<[string, number]> = []; + const writer = createFrameCoalescedParamWriter({ + write: async () => false, + onFailure: (paramId, value) => failures.push([paramId, value]), + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + }); + + writer.writeImmediately("reverbEnabled", 1); + + await expect(writer.flush()).resolves.toBe(false); + expect(frames.pendingFrames()).toBe(0); + expect(failures).toEqual([["reverbEnabled", 1]]); + writer.dispose(); + }); + + it("restores the last confirmed native value after a final optimistic toggle write fails", () => { + const nativeOff = schemaWithParams("OpenStudio NAM Rack", "NAM", [ + toggle("reverbPad", "Pad", 0, "space"), + ]); + const reconciler = createParamWriteReconciler(nativeOff); + + reconciler.beginOptimisticWrite("reverbPad", 1, 0); + const optimisticSchema = reconciler.applyToFallbackSchema(nativeOff); + expect(optimisticSchema?.parameters[0]?.value).toBe(1); + + expect(reconciler.resolveFailedWrite("reverbPad", 1)).toEqual({ + matched: true, + rollbackValue: 0, + }); + expect(reconciler.applyToFallbackSchema(nativeOff)?.parameters[0]?.value).toBe(0); + }); + + it("does not let an older failed write roll back a newer optimistic value", () => { + const nativeOff = schemaWithParams("OpenStudio NAM Rack", "NAM", [ + toggle("reverbPad", "Pad", 0, "space"), + ]); + const reconciler = createParamWriteReconciler(nativeOff); + + reconciler.beginOptimisticWrite("reverbPad", 1, 0); + reconciler.beginOptimisticWrite("reverbPad", 0, 1); + + expect(reconciler.resolveFailedWrite("reverbPad", 1)).toEqual({ matched: false }); + expect(reconciler.applyToFallbackSchema({ + ...nativeOff, + parameters: [{ ...nativeOff.parameters[0], value: 1 }], + })?.parameters[0]?.value).toBe(0); + }); + + it("requests one-shot readback for discrete writes and accepts the returned native value", () => { + const nativeOff = schemaWithParams("OpenStudio NAM Rack", "NAM", [ + toggle("reverbPad", "Pad", 0, "space"), + choice("reverbVoice", "Voice", 0, ["Studio", "Plate"], "space"), + continuous("reverbMix", "Mix", 0.3, 0, 1, "space"), + ]); + const reconciler = createParamWriteReconciler(nativeOff); + + expect(shouldReadBackAfterParamWrite(nativeOff, "reverbPad")).toBe(true); + expect(shouldReadBackAfterParamWrite(nativeOff, "reverbVoice")).toBe(true); + expect(shouldReadBackAfterParamWrite(nativeOff, "reverbMix")).toBe(false); + + reconciler.beginOptimisticWrite("reverbPad", 1, 0); + expect(reconciler.resolveSuccessfulWrite("reverbPad", 1)).toBe(true); + const readbackSchema = reconciler.acceptNativeSchema(nativeOff); + expect(readbackSchema?.parameters[0]?.value).toBe(0); + }); + + it("rejects stale schema refreshes so an old empty rack cannot replace a loaded capture", () => { + const gate = createSchemaRequestGate(); + const emptyRackRequest = gate.begin(); + const loadedRackRequest = gate.begin(); + + expect(gate.isLatest(emptyRackRequest)).toBe(false); + expect(gate.isLatest(loadedRackRequest)).toBe(true); + + gate.invalidate(); + expect(gate.isLatest(loadedRackRequest)).toBe(false); + }); + it("classifies built-in plugin schemas and selects primary controls", () => { const drums = schema("OpenStudio Drums", "Instrument"); const reverb = schema("S13 Reverb", "Reverb"); @@ -229,15 +524,4 @@ describe("BuiltInPluginPanel schema model", () => { } }); - it("keeps responsive CSS contracts for desktop, tablet, and narrow plugin panels", () => { - const css = readFileSync(new URL("../components/FXChainPanel.css", import.meta.url), "utf8"); - - expect(css).toContain("grid-template-columns: 400px 1fr"); - expect(css).toContain("grid-template-columns: repeat(auto-fit, minmax(164px, 1fr))"); - expect(css).toContain("@media (max-width: 900px)"); - expect(css).toContain("grid-template-columns: 1fr"); - expect(css).toContain("grid-template-rows: minmax(260px, 44vh) minmax(0, 1fr)"); - expect(css).toContain("@media (max-width: 520px)"); - expect(css).toContain("height: 112px"); - }); }); diff --git a/frontend/src/__tests__/channelStripEQIntegration.test.ts b/frontend/src/__tests__/channelStripEQIntegration.test.ts new file mode 100644 index 0000000..07cd5fa --- /dev/null +++ b/frontend/src/__tests__/channelStripEQIntegration.test.ts @@ -0,0 +1,31 @@ +// @ts-nocheck -- Vitest supplies Node globals/types outside the WebView build. +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; + +const read = (relative: string) => + readFileSync(new URL(relative, import.meta.url), "utf8"); + +describe("Channel Strip EQ bridge integration", () => { + it("loads and writes EQ power, phase, DC filter, and all six packed bands", () => { + const modal = read("../components/ChannelStripEQModal.tsx"); + expect(modal).toContain("nativeBridge.getChannelStripEQEnabled(trackId)"); + expect(modal).toContain("nativeBridge.getTrackPhaseInvert(trackId)"); + expect(modal).toContain("nativeBridge.getTrackDCOffset(trackId)"); + expect(modal).toContain("nativeBridge.setChannelStripEQEnabled(trackId, next)"); + expect(modal).toContain("nativeBridge.setTrackPhaseInvert(trackId, next)"); + expect(modal).toContain("nativeBridge.setTrackDCOffset(trackId, next)"); + expect(modal).toContain("const paramIndex = bandIndex * 4 + offset"); + expect(modal).toContain("EQ_BANDS.length * 4"); + expect(modal).toContain("Promise.all(parameterReads)"); + expect(modal).toContain("EQ_BANDS.map((definition, i)"); + }); + + it("maps the packed bridge onto real S13EQ band state instead of an empty parameter list", () => { + const processor = read("../../../Source/TrackProcessor.cpp"); + expect(processor).toContain("channelStripEQBandCount * channelStripEQValuesPerBand"); + expect(processor).toContain("channelStripEQ.bands[static_cast(surfaceBand)]"); + expect(processor).not.toMatch( + /setChannelStripEQParam[\s\S]{0,800}getParameters\(\)/, + ); + }); +}); diff --git a/frontend/src/__tests__/clipAndRoutingControlTransactions.test.ts b/frontend/src/__tests__/clipAndRoutingControlTransactions.test.ts new file mode 100644 index 0000000..78e3286 --- /dev/null +++ b/frontend/src/__tests__/clipAndRoutingControlTransactions.test.ts @@ -0,0 +1,410 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import clipPropertiesSource from "../components/ClipPropertiesPanel.tsx?raw"; +import routingMatrixSource from "../components/RoutingMatrix.tsx?raw"; +import trackRoutingSource from "../components/TrackRoutingModal.tsx?raw"; +import { + beginEditTransaction, + commitEditTransaction, + createEditTransactionLifecycle, +} from "../components/ui/editTransactionLifecycle"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type Track, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function audioClip(overrides: Partial = {}): AudioClip { + return { + id: "clip", + name: "Clip", + filePath: "C:/audio/clip.wav", + startTime: 0, + duration: 4, + offset: 0, + color: "#111111", + volumeDB: -6, + fadeIn: 0.2, + fadeOut: 0.3, + ...overrides, + }; +} + +function routingTrack(overrides: Partial = {}): Track { + return { + ...createDefaultTrack("source", "Source", "#111111", "audio", []), + sends: [{ + destTrackId: "dest", + level: 0.5, + pan: -0.25, + enabled: true, + preFader: false, + phaseInvert: false, + }], + clips: [audioClip()], + ...overrides, + }; +} + +function currentClip() { + const clip = useDAWStore.getState().tracks[0]?.clips[0]; + if (!clip) throw new Error("Missing test clip"); + return clip; +} + +function currentSend() { + const send = useDAWStore.getState().tracks[0]?.sends[0]; + if (!send) throw new Error("Missing test send"); + return send; +} + +function currentTrack() { + const track = useDAWStore.getState().tracks[0]; + if (!track) throw new Error("Missing test track"); + return track; +} + +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState({ + tracks: [routingTrack()], + selectedClipId: "clip", + selectedClipIds: ["clip"], + canUndo: false, + canRedo: false, + isModified: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("clip property edit transactions", () => { + it("renames a clip as one project-state command without unnecessary playback resync", () => { + const syncClips = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ syncClipsWithBackend: syncClips }); + + useDAWStore.getState().setClipName("clip", "Verse"); + expect(currentClip().name).toBe("Verse"); + expect(useDAWStore.getState().isModified).toBe(true); + expect(commandManager.getUndoStack().map((command) => command.type)) + .toEqual(["SET_CLIP_NAME"]); + expect(syncClips).not.toHaveBeenCalled(); + useDAWStore.getState().undo(); + expect(currentClip().name).toBe("Clip"); + useDAWStore.getState().redo(); + expect(currentClip().name).toBe("Verse"); + + commandManager.clear(); + useDAWStore.getState().setClipName("clip", "Verse"); + useDAWStore.getState().setClipName("missing", "Missing"); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("groups multi-packet gain and fade edits and synchronizes commit, undo, and redo", () => { + const syncClips = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ syncClipsWithBackend: syncClips }); + + let state = useDAWStore.getState(); + state.beginClipVolumeEdit("clip"); + state.setClipVolume("clip", -4); + state.setClipVolume("clip", -2); + expect(commandManager.getUndoStack()).toHaveLength(0); + state.commitClipVolumeEdit("clip"); + expect(currentClip().volumeDB).toBe(-2); + expect(commandManager.getUndoStack().map((command) => command.type)) + .toEqual(["SET_CLIP_VOLUME"]); + expect(syncClips).toHaveBeenCalledTimes(1); + + useDAWStore.getState().undo(); + expect(currentClip().volumeDB).toBe(-6); + expect(syncClips).toHaveBeenCalledTimes(2); + useDAWStore.getState().redo(); + expect(currentClip().volumeDB).toBe(-2); + expect(syncClips).toHaveBeenCalledTimes(3); + + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + state = useDAWStore.getState(); + state.beginClipFadeEdit("clip"); + state.previewClipFades("clip", 0.4, 0.3); + state.previewClipFades("clip", 0.7, 0.8); + expect(commandManager.getUndoStack()).toHaveLength(0); + state.commitClipFadeEdit("clip"); + expect([currentClip().fadeIn, currentClip().fadeOut]).toEqual([0.7, 0.8]); + expect(commandManager.getUndoStack().map((command) => command.type)) + .toEqual(["SET_CLIP_FADES"]); + expect(syncClips).toHaveBeenCalledTimes(4); + + useDAWStore.getState().undo(); + expect([currentClip().fadeIn, currentClip().fadeOut]).toEqual([0.2, 0.3]); + useDAWStore.getState().redo(); + expect([currentClip().fadeIn, currentClip().fadeOut]).toEqual([0.7, 0.8]); + expect(syncClips).toHaveBeenCalledTimes(6); + }); + + it("makes resets discrete transactions and rejects no-op or invalid edits", () => { + const syncClips = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ syncClipsWithBackend: syncClips }); + const state = useDAWStore.getState(); + + state.beginClipVolumeEdit("clip"); + state.setClipVolume("clip", 0); + state.commitClipVolumeEdit("clip"); + expect(currentClip().volumeDB).toBe(0); + expect(commandManager.getUndoStack()).toHaveLength(1); + + commandManager.clear(); + state.beginClipFadeEdit("clip"); + state.previewClipFades("clip", 0, 0.3); + state.commitClipFadeEdit("clip"); + expect(currentClip().fadeIn).toBe(0); + expect(commandManager.getUndoStack()).toHaveLength(1); + + commandManager.clear(); + state.beginClipFadeEdit("clip"); + state.previewClipFades("clip", 0, 0.3); + state.commitClipFadeEdit("clip"); + state.beginClipVolumeEdit("clip"); + state.setClipVolume("clip", Number.NaN); + state.commitClipVolumeEdit("clip"); + state.beginClipFadeEdit("missing"); + state.previewClipFades("missing", 1, 1); + state.commitClipFadeEdit("missing"); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(syncClips).toHaveBeenCalledTimes(2); + }); + + it("closes a cancel/unmount lifecycle exactly once with one undo command", () => { + const syncClips = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ syncClipsWithBackend: syncClips }); + const lifecycle = createEditTransactionLifecycle(); + + expect(beginEditTransaction( + lifecycle, + () => useDAWStore.getState().beginClipVolumeEdit("clip"), + () => useDAWStore.getState().commitClipVolumeEdit("clip"), + )).toBe(true); + useDAWStore.getState().setClipVolume("clip", -1); + useDAWStore.getState().setClipVolume("clip", 2); + expect(commitEditTransaction(lifecycle)).toBe(true); + expect(commitEditTransaction(lifecycle)).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(currentClip().volumeDB).toBe(2); + expect(syncClips).toHaveBeenCalledTimes(1); + }); +}); + +describe("routing send edit transactions", () => { + it("groups level and pan packets separately with exact native undo/redo synchronization", async () => { + const levelBridge = vi.spyOn(nativeBridge, "setTrackSendLevel").mockResolvedValue(true); + const panBridge = vi.spyOn(nativeBridge, "setTrackSendPan").mockResolvedValue(true); + let state = useDAWStore.getState(); + + state.beginTrackSendLevelEdit("source", 0); + await state.setTrackSendLevel("source", 0, 0.6); + await state.setTrackSendLevel("source", 0, 0.8); + state.commitTrackSendLevelEdit("source", 0); + expect(currentSend().level).toBe(0.8); + expect(commandManager.getUndoStack().map((command) => command.type)) + .toEqual(["SET_TRACK_SEND_LEVEL"]); + + state = useDAWStore.getState(); + state.beginTrackSendPanEdit("source", 0); + await state.setTrackSendPan("source", 0, 0.1); + await state.setTrackSendPan("source", 0, 0.75); + state.commitTrackSendPanEdit("source", 0); + expect(currentSend().pan).toBe(0.75); + expect(commandManager.getUndoStack().map((command) => command.type)) + .toEqual(["SET_TRACK_SEND_LEVEL", "SET_TRACK_SEND_PAN"]); + + useDAWStore.getState().undo(); + expect(currentSend().pan).toBe(-0.25); + expect(panBridge).toHaveBeenLastCalledWith("source", 0, -0.25); + useDAWStore.getState().undo(); + expect(currentSend().level).toBe(0.5); + expect(levelBridge).toHaveBeenLastCalledWith("source", 0, 0.5); + useDAWStore.getState().redo(); + useDAWStore.getState().redo(); + expect([currentSend().level, currentSend().pan]).toEqual([0.8, 0.75]); + expect(levelBridge).toHaveBeenLastCalledWith("source", 0, 0.8); + expect(panBridge).toHaveBeenLastCalledWith("source", 0, 0.75); + }); + + it("tracks direct resets, clamps bounds, and skips no-op or missing sends", async () => { + const levelBridge = vi.spyOn(nativeBridge, "setTrackSendLevel").mockResolvedValue(true); + const panBridge = vi.spyOn(nativeBridge, "setTrackSendPan").mockResolvedValue(true); + + await useDAWStore.getState().setTrackSendLevel("source", 0, 4); + await useDAWStore.getState().setTrackSendPan("source", 0, -4); + expect([currentSend().level, currentSend().pan]).toEqual([1, -1]); + expect(commandManager.getUndoStack().map((command) => command.type)) + .toEqual(["SET_TRACK_SEND_LEVEL", "SET_TRACK_SEND_PAN"]); + expect(levelBridge).toHaveBeenCalledWith("source", 0, 1); + expect(panBridge).toHaveBeenCalledWith("source", 0, -1); + + commandManager.clear(); + await useDAWStore.getState().setTrackSendLevel("source", 0, 1); + await useDAWStore.getState().setTrackSendPan("source", 0, Number.NaN); + await useDAWStore.getState().setTrackSendLevel("missing", 0, 0.2); + await useDAWStore.getState().setTrackSendPan("source", 5, 0.2); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("closes cancellation/unmount once and drops a transaction whose send disappeared", async () => { + vi.spyOn(nativeBridge, "setTrackSendLevel").mockResolvedValue(true); + const lifecycle = createEditTransactionLifecycle(); + expect(beginEditTransaction( + lifecycle, + () => useDAWStore.getState().beginTrackSendLevelEdit("source", 0), + () => useDAWStore.getState().commitTrackSendLevelEdit("source", 0), + )).toBe(true); + await useDAWStore.getState().setTrackSendLevel("source", 0, 0.75); + expect(commitEditTransaction(lifecycle)).toBe(true); + expect(commitEditTransaction(lifecycle)).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(1); + + commandManager.clear(); + useDAWStore.getState().beginTrackSendLevelEdit("source", 0); + await useDAWStore.getState().setTrackSendLevel("source", 0, 0.9); + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id === "source" + ? { ...track, sends: [] } + : track), + })); + useDAWStore.getState().commitTrackSendLevelEdit("source", 0); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.setState({ tracks: [routingTrack()] }); + const state = useDAWStore.getState(); + state.beginTrackSendLevelEdit("source", 0); + await state.setTrackSendLevel("source", 0, 0.7); + state.commitTrackSendLevelEdit("source", 0); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("groups the modal track pan and stereo-width controls with exact backend replay", async () => { + const panBridge = vi.spyOn(nativeBridge, "setTrackPan").mockResolvedValue(true); + const widthBridge = vi.spyOn(nativeBridge, "setTrackStereoWidth").mockResolvedValue(true); + let state = useDAWStore.getState(); + + state.beginTrackPanEdit("source"); + await state.setTrackPan("source", 0.2); + await state.setTrackPan("source", 0.6); + state.commitTrackPanEdit("source"); + expect(currentTrack().pan).toBe(0.6); + expect(commandManager.getUndoStack().map((command) => command.type)) + .toEqual(["SET_TRACK_PAN"]); + expect(panBridge).toHaveBeenCalledTimes(2); + + state = useDAWStore.getState(); + state.beginTrackStereoWidthEdit("source"); + await state.setTrackStereoWidth("source", 125); + await state.setTrackStereoWidth("source", 150); + state.commitTrackStereoWidthEdit("source"); + expect(currentTrack().stereoWidth).toBe(150); + expect(commandManager.getUndoStack().map((command) => command.type)) + .toEqual(["SET_TRACK_PAN", "SET_TRACK_STEREO_WIDTH"]); + expect(widthBridge).toHaveBeenCalledTimes(2); + + useDAWStore.getState().undo(); + expect(currentTrack().stereoWidth).toBe(100); + expect(widthBridge).toHaveBeenLastCalledWith("source", 100); + useDAWStore.getState().undo(); + expect(currentTrack().pan).toBe(0); + expect(panBridge).toHaveBeenLastCalledWith("source", 0); + useDAWStore.getState().redo(); + useDAWStore.getState().redo(); + expect([currentTrack().pan, currentTrack().stereoWidth]).toEqual([0.6, 150]); + expect(panBridge).toHaveBeenLastCalledWith("source", 0.6); + expect(widthBridge).toHaveBeenLastCalledWith("source", 150); + }); + + it("commits a typed track dB value once and skips unchanged or invalid control values", async () => { + const volumeBridge = vi.spyOn(nativeBridge, "setTrackVolume").mockResolvedValue(true); + const widthBridge = vi.spyOn(nativeBridge, "setTrackStereoWidth").mockResolvedValue(true); + const state = useDAWStore.getState(); + + state.beginTrackVolumeEdit("source"); + await state.setTrackVolume("source", -12); + state.commitTrackVolumeEdit("source"); + expect(currentTrack().volumeDB).toBe(-12); + expect(commandManager.getUndoStack().map((command) => command.type)) + .toEqual(["SET_TRACK_VOLUME"]); + expect(volumeBridge).toHaveBeenCalledTimes(1); + useDAWStore.getState().undo(); + expect(currentTrack().volumeDB).toBe(0); + useDAWStore.getState().redo(); + expect(currentTrack().volumeDB).toBe(-12); + + commandManager.clear(); + const volumeCalls = volumeBridge.mock.calls.length; + state.beginTrackVolumeEdit("source"); + await state.setTrackVolume("source", -12); + state.commitTrackVolumeEdit("source"); + state.beginTrackVolumeEdit("source"); + await state.setTrackVolume("source", Number.NaN); + state.commitTrackVolumeEdit("source"); + state.beginTrackStereoWidthEdit("source"); + await state.setTrackStereoWidth("source", 100); + state.commitTrackStereoWidthEdit("source"); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(volumeBridge).toHaveBeenCalledTimes(volumeCalls); + expect(widthBridge).not.toHaveBeenCalled(); + }); +}); + +describe("clip and routing control wiring", () => { + it("wires Clip Properties to stable begin/live/commit callbacks and reset defaults", () => { + expect(clipPropertiesSource).toContain("function ClipNameField("); + expect(clipPropertiesSource).toContain("useDAWStore.getState().setClipName(clipId, nextName)"); + expect(clipPropertiesSource).toContain('event.key === "Enter"'); + expect(clipPropertiesSource).toContain('event.key === "Escape"'); + expect(clipPropertiesSource).toContain(''); + expect(clipPropertiesSource).not.toContain("useDAWStore.setState((s) =>"); + expect(clipPropertiesSource).toContain("const beginVolumeEdit = useCallback"); + expect(clipPropertiesSource).toContain("const changeFadeIn = useCallback"); + expect(clipPropertiesSource).toContain("const changeFadeOut = useCallback"); + expect(clipPropertiesSource).toContain("onBeginEdit={beginVolumeEdit}"); + expect(clipPropertiesSource).toContain("onCommitEdit={commitVolumeEdit}"); + expect(clipPropertiesSource).toContain("onBeginEdit={beginFadeEdit}"); + expect(clipPropertiesSource).toContain("onCommitEdit={commitFadeEdit}"); + expect(clipPropertiesSource).toContain("previewClipFades(clipId, fadeIn, latestClip.fadeOut)"); + expect(clipPropertiesSource).toContain("previewClipFades(clipId, latestClip.fadeIn, fadeOut)"); + expect(clipPropertiesSource.match(/defaultValue=\{0\}/g)).toHaveLength(3); + expect(clipPropertiesSource).not.toContain("setClipFades(clip!"); + }); + + it("wires Routing Matrix and both routing-modal directions through shared transactions", () => { + expect(routingMatrixSource).toContain("const handleLevelEditBegin = useCallback"); + expect(routingMatrixSource).toContain("const handleLevelEditCommit = useCallback"); + expect(routingMatrixSource).toContain("onBeginEdit={handleLevelEditBegin}"); + expect(routingMatrixSource).toContain("onCommitEdit={handleLevelEditCommit}"); + expect(routingMatrixSource).toContain("defaultValue={50}"); + + expect(trackRoutingSource).toContain("function SendLevelSlider("); + expect(trackRoutingSource).toContain("function SendPanSlider("); + expect(trackRoutingSource).toContain("function TrackPanSlider("); + expect(trackRoutingSource).toContain("function TrackStereoWidthSlider("); + expect(trackRoutingSource).toContain("function TrackVolumeDbField("); + expect(trackRoutingSource).toContain("onBeginEdit={beginEdit}"); + expect(trackRoutingSource).toContain("onCommitEdit={commitEdit}"); + expect(trackRoutingSource.match(/'); + expect(trackRoutingSource).toContain(''); + expect(trackRoutingSource).toContain(''); + expect(trackRoutingSource).toContain('event.key === "Enter"'); + expect(trackRoutingSource).toContain('event.key === "Escape"'); + expect(trackRoutingSource).toContain("useDAWStore.getState().commitTrackVolumeEdit(trackId)"); + expect(trackRoutingSource).not.toContain("dbToLinear"); + expect(trackRoutingSource).not.toMatch(/onChange=\{\(e\) => setTrackSend(Level|Pan)/); + }); +}); diff --git a/frontend/src/__tests__/clipNormalization.test.ts b/frontend/src/__tests__/clipNormalization.test.ts new file mode 100644 index 0000000..ed823b3 --- /dev/null +++ b/frontend/src/__tests__/clipNormalization.test.ts @@ -0,0 +1,344 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + type Track, + useDAWStore, +} from "../store/useDAWStore"; +import { dispatchGlobalShortcut } from "../utils/globalShortcutDispatcher"; +import { + activateShortcutContext, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; +import type { KeyboardShortcutProfileId } from "../utils/shortcutProfiles"; +import type { ShortcutPlatform } from "../utils/platform"; + +const originalState = useDAWStore.getState(); + +function audioClip(id: string, overrides: Partial = {}): AudioClip { + return { + id, + filePath: `C:/audio/${id}.wav`, + name: id, + startTime: 0, + duration: 2, + offset: 0, + color: "#123456", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + ...overrides, + }; +} + +function midiClip(id: string): MIDIClip { + return { + id, + name: id, + startTime: 0, + duration: 2, + sourceLength: 2, + loopLength: 2, + events: [], + ccEvents: [], + color: "#654321", + }; +} + +function track(id: string, clips: AudioClip[] = [], midiClips: MIDIClip[] = []): Track { + return { + ...createDefaultTrack(id, id, "#222222", "audio", []), + clips, + midiClips, + }; +} + +function currentAudioClip(id: string): AudioClip | undefined { + return useDAWStore.getState().tracks.flatMap((candidate) => candidate.clips) + .find((clip) => clip.id === id); +} + +beforeEach(() => { + commandManager.clear(); + resetShortcutContextForTests(); + useDAWStore.setState({ + tracks: [], + selectedClipId: null, + selectedClipIds: [], + selectedTrackId: null, + selectedTrackIds: [], + keyboardShortcutProfileId: "openstudio", + customShortcuts: {}, + canUndo: false, + canRedo: false, + isModified: false, + syncClipsWithBackend: vi.fn(async () => {}), + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + resetShortcutContextForTests(); + useDAWStore.setState(originalState); +}); + +describe("selected audio clip peak normalization", () => { + it("analyzes each exact trimmed range and atomically normalizes only eligible audio", async () => { + const first = audioClip("first", { offset: 1.25, duration: 2.5, volumeDB: -3 }); + const second = audioClip("second", { offset: 0.5, duration: 1, volumeDB: 4 }); + const locked = audioClip("locked", { locked: true, volumeDB: -8 }); + const invalid = audioClip("invalid", { duration: 0, volumeDB: -9 }); + const midi = midiClip("midi"); + useDAWStore.setState({ + tracks: [track("track", [first, second, locked, invalid], [midi])], + selectedClipId: first.id, + selectedClipIds: [first.id, second.id, locked.id, invalid.id, midi.id], + }); + const peak = vi.spyOn(nativeBridge, "getAudioPeakAmplitude") + .mockResolvedValueOnce(0.5) + .mockResolvedValueOnce(2); + const sync = useDAWStore.getState().syncClipsWithBackend as ReturnType; + + await expect(useDAWStore.getState().normalizeSelectedClips()).resolves.toBe(true); + + expect(peak.mock.calls).toEqual([ + [first.filePath, 1.25, 2.5], + [second.filePath, 0.5, 1], + ]); + expect(currentAudioClip(first.id)?.volumeDB).toBeCloseTo(6.020599913, 8); + expect(currentAudioClip(second.id)?.volumeDB).toBeCloseTo(-6.020599913, 8); + expect(currentAudioClip(locked.id)?.volumeDB).toBe(-8); + expect(currentAudioClip(invalid.id)?.volumeDB).toBe(-9); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(sync).toHaveBeenCalledTimes(1); + + useDAWStore.getState().undo(); + expect(currentAudioClip(first.id)?.volumeDB).toBe(-3); + expect(currentAudioClip(second.id)?.volumeDB).toBe(4); + expect(sync).toHaveBeenCalledTimes(2); + + useDAWStore.getState().redo(); + expect(currentAudioClip(first.id)?.volumeDB).toBeCloseTo(6.020599913, 8); + expect(currentAudioClip(second.id)?.volumeDB).toBeCloseTo(-6.020599913, 8); + expect(sync).toHaveBeenCalledTimes(3); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("clamps extreme gain, accepts the primary-selection fallback, and skips silent or missing files", async () => { + const quiet = audioClip("quiet"); + useDAWStore.setState({ + tracks: [track("track", [quiet])], + selectedClipId: quiet.id, + selectedClipIds: [], + }); + vi.spyOn(nativeBridge, "getAudioPeakAmplitude").mockResolvedValue(0.001); + await expect(useDAWStore.getState().normalizeSelectedClips()).resolves.toBe(true); + expect(currentAudioClip(quiet.id)?.volumeDB).toBe(12); + + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + vi.mocked(nativeBridge.getAudioPeakAmplitude).mockResolvedValue(0); + await expect(useDAWStore.getState().normalizeSelectedClips()).resolves.toBe(false); + expect(currentAudioClip(quiet.id)?.volumeDB).toBe(12); + expect(commandManager.canUndo()).toBe(false); + + vi.mocked(nativeBridge.getAudioPeakAmplitude).mockResolvedValue(null); + await expect(useDAWStore.getState().normalizeSelectedClips()).resolves.toBe(false); + expect(commandManager.canUndo()).toBe(false); + + vi.mocked(nativeBridge.getAudioPeakAmplitude).mockResolvedValue(10000); + await expect(useDAWStore.getState().normalizeSelectedClips()).resolves.toBe(true); + expect(currentAudioClip(quiet.id)?.volumeDB).toBe(-60); + }); + + it("does not create history when the calculated gain is already applied", async () => { + const normalized = audioClip("normalized", { volumeDB: 0 }); + useDAWStore.setState({ + tracks: [track("track", [normalized])], + selectedClipId: normalized.id, + selectedClipIds: [normalized.id], + }); + vi.spyOn(nativeBridge, "getAudioPeakAmplitude").mockResolvedValue(1); + + await expect(useDAWStore.getState().normalizeSelectedClips()).resolves.toBe(false); + expect(commandManager.canUndo()).toBe(false); + expect(useDAWStore.getState().syncClipsWithBackend).not.toHaveBeenCalled(); + }); + + it("skips a clip changed while analysis is pending", async () => { + let resolvePeak: (peak: number | null) => void = () => {}; + const pendingPeak = new Promise((resolve) => { resolvePeak = resolve; }); + const clip = audioClip("changed", { offset: 1 }); + useDAWStore.setState({ + tracks: [track("track", [clip])], + selectedClipId: clip.id, + selectedClipIds: [clip.id], + }); + vi.spyOn(nativeBridge, "getAudioPeakAmplitude").mockReturnValue(pendingPeak); + + const normalization = useDAWStore.getState().normalizeSelectedClips(); + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((candidate) => ({ + ...candidate, + clips: candidate.clips.map((entry) => entry.id === clip.id + ? { ...entry, offset: 1.5 } + : entry), + })), + })); + resolvePeak(0.5); + + await expect(normalization).resolves.toBe(false); + expect(currentAudioClip(clip.id)?.offset).toBe(1.5); + expect(currentAudioClip(clip.id)?.volumeDB).toBe(0); + expect(commandManager.canUndo()).toBe(false); + }); + + it("does not apply a result to a clip removed from the selection while analysis is pending", async () => { + let resolvePeak: (peak: number | null) => void = () => {}; + const pendingPeak = new Promise((resolve) => { resolvePeak = resolve; }); + const clip = audioClip("deselected"); + useDAWStore.setState({ + tracks: [track("track", [clip])], + selectedClipId: clip.id, + selectedClipIds: [clip.id], + }); + vi.spyOn(nativeBridge, "getAudioPeakAmplitude").mockReturnValue(pendingPeak); + + const normalization = useDAWStore.getState().normalizeSelectedClips(); + useDAWStore.setState({ selectedClipId: null, selectedClipIds: [] }); + resolvePeak(0.5); + + await expect(normalization).resolves.toBe(false); + expect(currentAudioClip(clip.id)?.volumeDB).toBe(0); + expect(commandManager.canUndo()).toBe(false); + }); + + it("lets only the latest overlapping request mutate the same clip", async () => { + let resolveFirst: (peak: number | null) => void = () => {}; + let resolveSecond: (peak: number | null) => void = () => {}; + const firstPeak = new Promise((resolve) => { resolveFirst = resolve; }); + const secondPeak = new Promise((resolve) => { resolveSecond = resolve; }); + const clip = audioClip("race"); + useDAWStore.setState({ + tracks: [track("track", [clip])], + selectedClipId: clip.id, + selectedClipIds: [clip.id], + }); + vi.spyOn(nativeBridge, "getAudioPeakAmplitude") + .mockReturnValueOnce(firstPeak) + .mockReturnValueOnce(secondPeak); + + const firstRequest = useDAWStore.getState().normalizeSelectedClips(); + const secondRequest = useDAWStore.getState().normalizeSelectedClips(); + resolveSecond(0.5); + await expect(secondRequest).resolves.toBe(true); + resolveFirst(0.25); + await expect(firstRequest).resolves.toBe(false); + + expect(currentAudioClip(clip.id)?.volumeDB).toBeCloseTo(6.020599913, 8); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); +}); + +describe("normalization shortcut profiles", () => { + const cases: Array<{ + profile: KeyboardShortcutProfileId; + platform: ShortcutPlatform; + event: { key: string; code: string; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }; + }> = [ + { profile: "reaper", platform: "windows", event: { key: "n", code: "KeyN" } }, + { profile: "reaper", platform: "macos", event: { key: "n", code: "KeyN" } }, + { profile: "studio_one", platform: "windows", event: { key: "n", code: "KeyN", altKey: true } }, + { profile: "studio_one", platform: "macos", event: { key: "n", code: "KeyN", altKey: true } }, + { profile: "mixcraft", platform: "windows", event: { key: "k", code: "KeyK", ctrlKey: true } }, + { profile: "mixcraft", platform: "macos", event: { key: "k", code: "KeyK", metaKey: true } }, + ]; + + it.each(cases)("dispatches $profile peak normalization on $platform", ({ profile, platform, event }) => { + const clip = audioClip("dispatch"); + useDAWStore.setState({ + tracks: [track("track", [clip])], + selectedClipId: clip.id, + selectedClipIds: [clip.id], + keyboardShortcutProfileId: profile, + }); + activateShortcutContext({ kind: "timeline" }); + const actionIds: string[] = []; + const preventDefault = vi.fn(); + + expect(dispatchGlobalShortcut( + { ...event, preventDefault, source: "clip-normalization-test" }, + platform, + { executeAction: (action) => actionIds.push(action.id) }, + )).toBe(true); + expect(actionIds).toEqual(["edit.normalizeClips"]); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("is timeline-only and unavailable for global/item/clip locks, frozen tracks, MIDI-only, or invalid selections", () => { + const locked = audioClip("locked", { locked: true }); + const midi = midiClip("midi"); + useDAWStore.setState({ + tracks: [track("track", [locked], [midi])], + selectedClipId: locked.id, + selectedClipIds: [locked.id, midi.id], + }); + const action = getRegisteredAction("edit.normalizeClips")!; + const executeAction = vi.fn(); + const expectShortcutUnavailable = () => { + activateShortcutContext({ kind: "timeline" }); + useDAWStore.setState({ keyboardShortcutProfileId: "reaper" }); + expect(dispatchGlobalShortcut( + { key: "n", code: "KeyN", source: "clip-normalization-lock-test" }, + "windows", + { executeAction }, + )).toBe(false); + expect(executeAction).not.toHaveBeenCalled(); + }; + expect(action.shortcutScope).toBe("timeline"); + expect(action.canHandleShortcut?.()).toBe(false); + expectShortcutUnavailable(); + + const valid = audioClip("valid"); + const validTrack = track("track", [valid]); + useDAWStore.setState({ + tracks: [validTrack], + selectedClipId: valid.id, + selectedClipIds: [valid.id], + globalLocked: true, + }); + expect(action.canHandleShortcut?.()).toBe(false); + expectShortcutUnavailable(); + useDAWStore.setState({ + globalLocked: false, + lockSettings: { ...useDAWStore.getState().lockSettings, items: true }, + }); + expect(action.canHandleShortcut?.()).toBe(false); + expectShortcutUnavailable(); + useDAWStore.setState({ + lockSettings: { ...useDAWStore.getState().lockSettings, items: false }, + tracks: [{ ...validTrack, frozen: true }], + }); + expect(action.canHandleShortcut?.()).toBe(false); + expectShortcutUnavailable(); + + useDAWStore.setState({ + tracks: [track("track", [audioClip("valid")])], + selectedClipId: "valid", + selectedClipIds: ["valid"], + }); + expect(action.canHandleShortcut?.()).toBe(true); + activateShortcutContext({ kind: "mixer" }); + useDAWStore.setState({ keyboardShortcutProfileId: "reaper" }); + expect(dispatchGlobalShortcut( + { key: "n", code: "KeyN", source: "clip-normalization-test" }, + "windows", + { executeAction: () => { throw new Error("must not dispatch"); } }, + )).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/clipNormalizationNativeContract.test.ts b/frontend/src/__tests__/clipNormalizationNativeContract.test.ts new file mode 100644 index 0000000..bac6b65 --- /dev/null +++ b/frontend/src/__tests__/clipNormalizationNativeContract.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import audioEngineHeader from "../../../Source/AudioEngine.h?raw"; +import audioEngineSource from "../../../Source/AudioEngine.cpp?raw"; +import mainComponentHeader from "../../../Source/MainComponent.h?raw"; +import mainComponentSource from "../../../Source/MainComponent.cpp?raw"; +import nativeBridgeSource from "../services/NativeBridge.ts?raw"; + +describe("clip normalization native contract", () => { + it("exposes an asynchronous exact-range peak bridge from TypeScript through JUCE", () => { + expect(nativeBridgeSource).toContain("getAudioPeakAmplitude?: ("); + expect(nativeBridgeSource).toContain("window.__JUCE__.backend.getAudioPeakAmplitude("); + expect(mainComponentSource).toContain('.withNativeFunction ("getAudioPeakAmplitude"'); + expect(mainComponentSource).toContain("clipPeakAnalysisPool.addJob"); + expect(mainComponentSource).toContain("audioEngine.getAudioPeakAmplitude("); + expect(mainComponentHeader).toContain("juce::ThreadPool clipPeakAnalysisPool"); + expect(audioEngineHeader).toContain("double getAudioPeakAmplitude(const juce::String& filePath"); + }); + + it("reads the trimmed sample window in blocks and inspects every channel", () => { + expect(audioEngineSource).toContain("std::floor(exactStart)"); + expect(audioEngineSource).toContain("std::ceil(juce::jmin("); + expect(audioEngineSource).toContain("for (juce::int64 blockStart = startSample; blockStart < endSample;)"); + expect(audioEngineSource).toContain("for (int channel = 0; channel < numChannels; ++channel)"); + expect(audioEngineSource).toContain("for (int sample = 0; sample < samplesThisBlock; ++sample)"); + expect(audioEngineSource).toContain("peak = juce::jmax(peak, static_cast(std::abs(value)))"); + expect(audioEngineSource).toContain("if (! std::isfinite(value))"); + }); +}); diff --git a/frontend/src/__tests__/commandSurfaceReachability.test.ts b/frontend/src/__tests__/commandSurfaceReachability.test.ts new file mode 100644 index 0000000..bfef318 --- /dev/null +++ b/frontend/src/__tests__/commandSurfaceReachability.test.ts @@ -0,0 +1,460 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + executeActiveScopedAction, + getActionShortcutScopes, + getRegisteredAction, + registerScopedActionExecutor, +} from "../store/actionRegistry"; +import { useDAWStore } from "../store/useDAWStore"; +import { + dispatchGlobalShortcut, + matchesActionShortcut, + resolveRegistryShortcutAction, +} from "../utils/globalShortcutDispatcher"; +import { + registerModalShortcutScope, + registerTransientOverlayShortcutScope, + routeModalShortcutEvent, +} from "../utils/modalShortcutScope"; +import { getProfileActionBindings } from "../utils/shortcutProfiles"; +import { + activateShortcutContext, + getActiveShortcutContext, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; +import { getShortcutPlatform } from "../utils/platform"; + +const cleanup: Array<() => void> = []; +const originalInputState = { + keyboardShortcutProfileId: useDAWStore.getState().keyboardShortcutProfileId, + customShortcuts: useDAWStore.getState().customShortcuts, +}; + +function hostPrimaryModifier(): { ctrlKey: true } | { metaKey: true } { + return getShortcutPlatform() === "macos" ? { metaKey: true } : { ctrlKey: true }; +} + +class ShortcutTestTarget extends EventTarget { + constructor(private readonly editable = false) { + super(); + } + + closest(): object | null { + return this.editable ? {} : null; + } +} + +function createShortcutEvent( + key: string, + modifiers: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean; shiftKey?: boolean } = {}, +): Event { + const event = new Event("keydown", { bubbles: true, cancelable: true }); + Object.defineProperties(event, { + key: { value: key }, + code: { value: key }, + ctrlKey: { value: Boolean(modifiers.ctrlKey) }, + metaKey: { value: Boolean(modifiers.metaKey) }, + altKey: { value: Boolean(modifiers.altKey) }, + shiftKey: { value: Boolean(modifiers.shiftKey) }, + }); + return event; +} + +afterEach(() => { + while (cleanup.length > 0) cleanup.pop()?.(); + resetShortcutContextForTests(); + useDAWStore.setState(originalInputState); + vi.restoreAllMocks(); +}); + +describe("visible component command reachability", () => { + it("registers each newly audited command in its owning surface", () => { + const expected = new Map([ + ["modal.close", "modal"], + ["script.runCurrent", "modal"], + ["script.saveCurrent", "modal"], + ["script.clearConsole", "modal"], + ["script.refreshFiles", "modal"], + ["script.openFolder", "modal"], + ["script.showEditorTab", "modal"], + ["script.showFilesTab", "modal"], + ["browser.close", "browser"], + ["fx.close", "plugin"], + ["mixer.close", "mixer"], + ]); + + for (const [actionId, scope] of expected) { + const action = getRegisteredAction(actionId); + expect(action, actionId).toBeDefined(); + expect(getActionShortcutScopes(action!), actionId).toContain(scope); + } + expect(getRegisteredAction("modal.close")?.shortcut).toBe("Esc"); + expect(getRegisteredAction("script.runCurrent")?.shortcut).toBe("Ctrl+Enter"); + expect(getRegisteredAction("browser.close")?.shortcut).toBeUndefined(); + expect(getRegisteredAction("fx.close")?.shortcut).toBeUndefined(); + expect(getRegisteredAction("mixer.close")?.shortcut).toBeUndefined(); + }); + + it("closes only the top nested modal and restores the exact previous owner", () => { + activateShortcutContext({ kind: "timeline" }); + const outerClose = vi.fn(); + const innerClose = vi.fn(); + const unregisterOuter = registerModalShortcutScope(outerClose); + cleanup.push(unregisterOuter); + const unregisterInner = registerModalShortcutScope(innerClose); + cleanup.push(unregisterInner); + + expect(getActiveShortcutContext()).toEqual({ kind: "modal" }); + expect(executeActiveScopedAction("modal.close")).toBe("handled"); + expect(innerClose).toHaveBeenCalledTimes(1); + expect(outerClose).not.toHaveBeenCalled(); + + cleanup.pop()?.(); + expect(getActiveShortcutContext()).toEqual({ kind: "modal" }); + expect(executeActiveScopedAction("modal.close")).toBe("handled"); + expect(outerClose).toHaveBeenCalledTimes(1); + + cleanup.pop()?.(); + expect(getActiveShortcutContext()).toEqual({ kind: "timeline" }); + }); + + it("does not leak a blocked top-modal close command to the dialog underneath", () => { + const outerClose = vi.fn(); + const innerClose = vi.fn(); + cleanup.push(registerModalShortcutScope(outerClose)); + cleanup.push(registerModalShortcutScope(innerClose, { canClose: () => false })); + + expect(executeActiveScopedAction("modal.close")).toBe("claimed_noop"); + expect(innerClose).not.toHaveBeenCalled(); + expect(outerClose).not.toHaveBeenCalled(); + }); + + it("gives a nested transient overlay one close command and restores its underlay", () => { + useDAWStore.setState({ keyboardShortcutProfileId: "openstudio", customShortcuts: {} }); + const target = new ShortcutTestTarget(); + const outerClose = vi.fn(); + const innerClose = vi.fn(); + const unregisterOuter = registerTransientOverlayShortcutScope(outerClose, { eventTarget: target }); + cleanup.push(unregisterOuter); + const unregisterInner = registerTransientOverlayShortcutScope(innerClose, { eventTarget: target }); + cleanup.push(unregisterInner); + + target.dispatchEvent(createShortcutEvent("Escape")); + expect(innerClose).toHaveBeenCalledTimes(1); + expect(outerClose).not.toHaveBeenCalled(); + + cleanup.pop()?.(); + target.dispatchEvent(createShortcutEvent("Escape")); + expect(outerClose).toHaveBeenCalledTimes(1); + }); + + it("honors transient close unbinding and an editable rebound command", () => { + const close = vi.fn(); + const editableTarget = new ShortcutTestTarget(true); + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { + "modal.close": { windows: [], macos: [] }, + }, + }); + cleanup.push(registerTransientOverlayShortcutScope(close, { eventTarget: editableTarget })); + + const unassignedEscape = createShortcutEvent("Escape"); + editableTarget.dispatchEvent(unassignedEscape); + expect(close).not.toHaveBeenCalled(); + expect(unassignedEscape.defaultPrevented).toBe(false); + + cleanup.pop()?.(); + useDAWStore.setState({ + customShortcuts: { + "modal.close": { windows: ["Control+F9"], macos: ["Command+F9"] }, + }, + }); + cleanup.push(registerTransientOverlayShortcutScope(close, { eventTarget: editableTarget })); + editableTarget.dispatchEvent(createShortcutEvent("Escape")); + expect(close).not.toHaveBeenCalled(); + + const rebound = createShortcutEvent("F9", hostPrimaryModifier()); + editableTarget.dispatchEvent(rebound); + expect(close).toHaveBeenCalledTimes(1); + expect(rebound.defaultPrevented).toBe(true); + }); + + it("preserves a native editable chord even when assigned to transient close", () => { + const close = vi.fn(); + const editableTarget = new ShortcutTestTarget(true); + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { + "modal.close": { windows: ["Control+C"], macos: ["Command+C"] }, + }, + }); + cleanup.push(registerTransientOverlayShortcutScope(close, { eventTarget: editableTarget })); + + const copy = createShortcutEvent("c", hostPrimaryModifier()); + editableTarget.dispatchEvent(copy); + expect(close).not.toHaveBeenCalled(); + expect(copy.defaultPrevented).toBe(false); + }); + + it("does not let a Script Editor binding consume keys in an unrelated modal", () => { + cleanup.push(registerModalShortcutScope(vi.fn())); + expect(getRegisteredAction("modal.close")?.canHandleShortcut?.()).toBe(true); + expect(getRegisteredAction("script.runCurrent")?.canHandleShortcut?.()).toBe(false); + + const run = vi.fn(() => "handled" as const); + cleanup.push(registerScopedActionExecutor( + { kind: "modal" }, + run, + ["script.runCurrent"], + )); + expect(getRegisteredAction("script.runCurrent")?.canHandleShortcut?.()).toBe(true); + getRegisteredAction("script.runCurrent")?.execute(); + expect(run).toHaveBeenCalledWith("script.runCurrent"); + }); + + it("does not consume a plug-in command unless the exact active owner advertises it", () => { + const builtInOwner = vi.fn((actionId: string) => ( + actionId === "fx.close" ? "handled" as const : "unmatched" as const + )); + cleanup.push(registerScopedActionExecutor( + { kind: "plugin", sessionId: "builtin-editor" }, + builtInOwner, + ["fx.close"], + )); + activateShortcutContext({ kind: "plugin", sessionId: "builtin-editor" }); + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { + "fx.add": { windows: ["Control+F8"], macos: ["Command+F8"] }, + }, + }); + const event = { key: "F8", code: "F8", ctrlKey: true }; + + expect(getRegisteredAction("fx.add")?.canHandleShortcut?.()).toBe(false); + expect(resolveRegistryShortcutAction(event, "windows")).toBeNull(); + expect(dispatchGlobalShortcut({ ...event, source: "browser" }, "windows")).toBe(false); + expect(builtInOwner).not.toHaveBeenCalled(); + + const chainOwner = vi.fn(() => "handled" as const); + cleanup.push(registerScopedActionExecutor( + { kind: "plugin", sessionId: "builtin-editor" }, + chainOwner, + ["fx.add"], + )); + expect(getRegisteredAction("fx.add")?.canHandleShortcut?.()).toBe(true); + expect(dispatchGlobalShortcut({ ...event, source: "browser" }, "windows")).toBe(true); + expect(chainOwner).toHaveBeenCalledWith("fx.add"); + }); + + it("leaves editable Escape to the dialog and dispatches non-editable Escape once", () => { + const close = vi.fn(); + cleanup.push(registerModalShortcutScope(close)); + + expect(dispatchGlobalShortcut({ + key: "Escape", + code: "Escape", + source: "browser", + targetIsEditable: true, + }, "windows")).toBe(false); + expect(close).not.toHaveBeenCalled(); + + const preventDefault = vi.fn(); + const stopImmediatePropagation = vi.fn(); + expect(dispatchGlobalShortcut({ + key: "Escape", + code: "Escape", + source: "browser", + targetIsEditable: false, + preventDefault, + stopImmediatePropagation, + }, "windows")).toBe(true); + expect(close).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); + }); + + it("routes editable modal close locally without double-firing and honors close blocking", () => { + const close = vi.fn(); + cleanup.push(registerModalShortcutScope(close)); + const editableTarget = { + closest: vi.fn(() => ({})), + } as unknown as EventTarget; + const preventDefault = vi.fn(); + const stopImmediatePropagation = vi.fn(); + + const routed = routeModalShortcutEvent({ + key: "Escape", + code: "Escape", + target: editableTarget, + preventDefault, + stopImmediatePropagation, + }, "windows"); + + expect(routed).toEqual({ + matched: true, + preservedEditableCommand: false, + result: "handled", + suppressedHeadlessEscape: true, + }); + expect(close).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); + + cleanup.pop()?.(); + cleanup.push(registerModalShortcutScope(close, { canClose: () => false })); + expect(routeModalShortcutEvent({ + key: "Escape", + code: "Escape", + target: editableTarget, + }, "windows").result).toBe("claimed_noop"); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("preserves an editable unmatched Escape and suppresses Headless UI raw close", () => { + const close = vi.fn(); + cleanup.push(registerModalShortcutScope(close)); + useDAWStore.setState({ + customShortcuts: { + "modal.close": { + windows: [], + macos: [], + }, + }, + }); + const preventDefault = vi.fn(); + const stopImmediatePropagation = vi.fn(); + const routed = routeModalShortcutEvent({ + key: "Escape", + code: "Escape", + target: { closest: () => ({}) } as unknown as EventTarget, + preventDefault, + stopImmediatePropagation, + }, "windows"); + + expect(routed).toEqual({ + matched: false, + preservedEditableCommand: false, + result: "unmatched", + suppressedHeadlessEscape: true, + }); + expect(close).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + expect(stopImmediatePropagation).toHaveBeenCalledTimes(1); + }); + + it("uses a rebound modal close key and leaves raw Escape unassigned", () => { + const close = vi.fn(); + cleanup.push(registerModalShortcutScope(close)); + useDAWStore.setState({ + customShortcuts: { + "modal.close": { + windows: ["Control+F9"], + macos: ["Command+F9"], + }, + }, + }); + const editableTarget = { closest: () => ({}) } as unknown as EventTarget; + + expect(routeModalShortcutEvent({ + key: "Escape", + code: "Escape", + target: editableTarget, + }, "windows")).toMatchObject({ matched: false, result: "unmatched" }); + expect(close).not.toHaveBeenCalled(); + + expect(routeModalShortcutEvent({ + key: "F9", + code: "F9", + ctrlKey: true, + target: editableTarget, + }, "windows")).toMatchObject({ matched: true, result: "handled" }); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("retains an explicitly documented Esc safety binding in every strict profile", () => { + for (const profileId of ["digital_performer", "waveform", "renoise"] as const) { + expect(getProfileActionBindings(profileId, "modal.close", "windows"), profileId) + .toEqual(["Esc"]); + expect(getProfileActionBindings(profileId, "modal.close", "macos"), profileId) + .toEqual(["Esc"]); + } + }); + + it("resolves the Script Editor run chord on Windows and macOS and honors custom bindings", () => { + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: {}, + }); + expect(matchesActionShortcut({ + key: "Enter", + code: "Enter", + ctrlKey: true, + }, "script.runCurrent", "windows")).toBe(true); + expect(matchesActionShortcut({ + key: "Enter", + code: "Enter", + metaKey: true, + }, "script.runCurrent", "macos")).toBe(true); + + useDAWStore.setState({ + customShortcuts: { + "script.runCurrent": { + windows: ["Control+F9"], + macos: ["Command+F9"], + }, + }, + }); + expect(matchesActionShortcut({ + key: "F9", + code: "F9", + ctrlKey: true, + }, "script.runCurrent", "windows")).toBe(true); + expect(matchesActionShortcut({ + key: "F9", + code: "F9", + metaKey: true, + }, "script.runCurrent", "macos")).toBe(true); + expect(matchesActionShortcut({ + key: "Enter", + code: "Enter", + ctrlKey: true, + }, "script.runCurrent", "windows")).toBe(false); + }); + + it("routes close commands to the exact active browser and plug-in owner", () => { + const browserUnderlay = vi.fn(() => "handled" as const); + const browserTop = vi.fn(() => "handled" as const); + cleanup.push(registerScopedActionExecutor( + { kind: "browser" }, + browserUnderlay, + ["browser.close"], + )); + cleanup.push(registerScopedActionExecutor( + { kind: "browser" }, + browserTop, + ["browser.close"], + )); + activateShortcutContext({ kind: "browser" }); + expect(executeActiveScopedAction("browser.close")).toBe("handled"); + expect(browserTop).toHaveBeenCalledWith("browser.close"); + expect(browserUnderlay).not.toHaveBeenCalled(); + + const dockedFx = vi.fn(() => "handled" as const); + const detachedFx = vi.fn(() => "handled" as const); + cleanup.push(registerScopedActionExecutor( + { kind: "plugin", sessionId: "docked" }, + dockedFx, + ["fx.close"], + )); + cleanup.push(registerScopedActionExecutor( + { kind: "plugin", sessionId: "detached" }, + detachedFx, + ["fx.close"], + )); + activateShortcutContext({ kind: "plugin", sessionId: "detached" }); + expect(executeActiveScopedAction("fx.close")).toBe("handled"); + expect(detachedFx).toHaveBeenCalledWith("fx.close"); + expect(dockedFx).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/componentScopedActionExecutors.test.ts b/frontend/src/__tests__/componentScopedActionExecutors.test.ts new file mode 100644 index 0000000..c0719ed --- /dev/null +++ b/frontend/src/__tests__/componentScopedActionExecutors.test.ts @@ -0,0 +1,283 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import timelineSource from "../components/Timeline.tsx?raw"; +import pianoRollSource from "../components/PianoRoll.tsx?raw"; +import pitchEditorSource from "../components/PitchEditorLowerZone.tsx?raw"; +import trackHeaderSource from "../components/TrackHeader.tsx?raw"; +import aiTrackHeaderSource from "../components/AITrackHeader.tsx?raw"; +import fxChainSource from "../components/FXChainPanel.tsx?raw"; +import transportBarSource from "../components/TransportBar.tsx?raw"; +import mainToolbarSource from "../components/MainToolbar.tsx?raw"; +import mixerPanelSource from "../components/MixerPanel.tsx?raw"; +import masterTrackHeaderSource from "../components/MasterTrackHeader.tsx?raw"; +import channelStripSource from "../components/ChannelStrip.tsx?raw"; +import pluginBrowserSource from "../components/PluginBrowser.tsx?raw"; +import mediaExplorerSource from "../components/MediaExplorer.tsx?raw"; +import aiTrackSource from "../components/AITrackHeader.tsx?raw"; +import aiWorkflowModalSource from "../components/AIWorkflowModal.tsx?raw"; +import aiWorkflowParamSource from "../components/AIWorkflowParamField.tsx?raw"; +import { + executeActiveScopedAction, + registerScopedActionExecutor, +} from "../store/actionRegistry"; +import { + activateShortcutContext, + type EditShortcutContext, +} from "../utils/shortcutContext"; + +const cleanups: Array<() => void> = []; + +function normalizeSourceText(source: string): string { + return source.replace(/\r\n?/g, "\n"); +} + +afterEach(() => { + while (cleanups.length > 0) cleanups.pop()?.(); + activateShortcutContext({ kind: "application" }); +}); + +describe("component-owned scoped action wiring", () => { + it("splits only the clip and pointer captured by the active Timeline instance", () => { + expect(timelineSource).toContain('actionId !== "clip.splitAtPointer"'); + expect(timelineSource).toContain("const menu = clipContextMenu"); + expect(timelineSource).toContain("state.splitMIDIClipAtPosition(menu.clipId, menu.time)"); + expect(timelineSource).toContain("state.splitClipAtPosition(menu.clipId, menu.time)"); + expect(normalizeSourceText(timelineSource)).toContain('registerScopedActionExecutor(\n context,'); + expect(timelineSource).toContain('matchesActionShortcut(event, "clip.splitAtPointer")'); + }); + + it("executes selection-dependent Piano Roll actions inside the exact editor session", () => { + for (const actionId of [ + "midi.loopFromSelectedNotes", + "midi.noteProperties", + "midi.toggleGhostReference", + "midi.configureControllerLanes", + "midi.openQuantizePanel", + "midi.quantizeLength", + "midi.controllerLine", + "midi.controllerTransform", + "midi.controllerThin", + "midi.copyControllerLane", + "midi.pasteControllerLane", + "midi.clearControllerLane", + ]) { + expect(pianoRollSource).toContain(`actionId === "${actionId}"`); + } + expect(pianoRollSource).toContain("parseNotePairs(getLatestClipEvents())"); + expect(pianoRollSource).toContain("setLoopRegion(clipStartTime + start, clipStartTime + end)"); + expect(pianoRollSource).toContain("setTransformDialog({ type: \"velocity\", value: selectedPair.velocity })"); + expect(pianoRollSource).toContain("setShowGhostMIDIClips((visible) => !visible)"); + expect(pianoRollSource).toContain("const controllerLaneSelectorRef = useRef(null)"); + expect(pianoRollSource).toContain("selector.focus()"); + expect(pianoRollSource).toContain("selector.showPicker?.()"); + expect(pianoRollSource).toContain("ref={controllerLaneSelectorRef}"); + expect(pianoRollSource).toContain("pianoShortcutContext,"); + }); + + it("routes pitch actions through the active editor store and its undo-aware correction", () => { + expect(pitchEditorSource).toContain('actionId === "pitch.detectKeyScale"'); + expect(pitchEditorSource).toContain("autoDetectScale()"); + expect(pitchEditorSource).toContain('actionId === "pitch.correctAllToScale"'); + expect(pitchEditorSource).toContain("correctAllToScale()"); + expect(pitchEditorSource).toContain('actionId === "pitch.toggleAB"'); + expect(pitchEditorSource).toContain("toggleABCompare()"); + expect(pitchEditorSource).toContain('actionId === "pitch.openCorrectionMacro"'); + expect(pitchEditorSource).toContain("toggleCorrectPitchModal()"); + expect(normalizeSourceText(pitchEditorSource)).toContain('registerScopedActionExecutor(\n context,'); + }); + + it("opens only the primary selected standard or AI TrackHeader and supports the main mixer context", () => { + for (const source of [trackHeaderSource, aiTrackHeaderSource].map(normalizeSourceText)) { + expect(source).toContain("if (!isSelected || selectedTrackId !== track.id) return"); + expect(source).toContain("if (selectedId !== track.id) return \"claimed_noop\""); + expect(source).toContain('registerScopedActionExecutor(\n { kind: "track_control_panel" }'); + expect(source).toContain('registerScopedActionExecutor(\n { kind: "mixer" }'); + expect(source).toContain("setShowFXChain(true)"); + expect(source).toContain('data-shortcut-context="track_control_panel"'); + } + expect(trackHeaderSource).toContain('actionId === "track.openSelectedFxChain"'); + expect(aiTrackHeaderSource).toContain('actionId !== "track.openSelectedFxChain"'); + expect(trackHeaderSource).toContain('actionId === "track.openSelectedNotes"'); + expect(trackHeaderSource).toContain('actionId === "track.loadSelectedSamplerSample"'); + }); + + it("keeps FX actions on the selected slot and reuses undo-aware mutations", () => { + expect(fxChainSource).toContain("const [selectedFxIndex, setSelectedFxIndex]"); + expect(fxChainSource).toContain('actionId === "fx.add"'); + expect(fxChainSource).toContain('actionId === "fx.removeSelected"'); + expect(fxChainSource).toContain('actionId === "fx.toggleSelectedBypass"'); + expect(fxChainSource).toContain('actionId !== "fx.openSelectedEditor"'); + for (const actionId of [ + "fx.toggleSelectedAB", + "fx.reloadSelectedScript", + "fx.toggleSelectedParameters", + "fx.toggleSelectedPresets", + "fx.openInstrumentEditor", + "fx.removeInstrument", + ]) { + expect(fxChainSource).toContain(`actionId === "${actionId}"`); + } + expect(fxChainSource).toContain("removeTrackFXWithUndo(trackId, fxIndex, chainType)"); + expect(fxChainSource).toContain("removeMasterFXWithUndo(fxIndex)"); + expect(fxChainSource).toContain("toggleFXSlotBypassWithUndo(trackId, fxIndex, chainType)"); + expect(fxChainSource).not.toContain('if (chainType === "master") return "claimed_noop"'); + expect(fxChainSource).toContain('selectedFx.type === "builtin"'); + expect(fxChainSource).toContain("handleOpenEditor(selectedFx.index)"); + expect(fxChainSource).toContain('data-shortcut-context={`plugin:${fxShortcutSessionId}`}'); + }); + + it("opens the exact FX panel's chooser without guessing a plug-in", () => { + expect(fxChainSource).toContain("const availablePluginSearchRef = useRef(null)"); + expect(fxChainSource).toContain("searchInput.scrollIntoView({ block: \"nearest\", inline: \"nearest\" })"); + expect(fxChainSource).toContain("searchInput.focus()"); + expect(fxChainSource).toContain("searchInput.select()"); + expect(fxChainSource).toContain("ref={availablePluginSearchRef}"); + const addActionBranch = fxChainSource.slice( + fxChainSource.indexOf('if (actionId === "fx.add")'), + fxChainSource.indexOf("const selectedFx = selectedFxIndex"), + ); + expect(addActionBranch).not.toContain("handleAddPlugin("); + }); + + it("wires shell, mixer, browser, and media commands to their real UI owners", () => { + expect(transportBarSource).toContain('actionId !== "transport.metronomeSettings"'); + expect(transportBarSource).toContain("setShowMetronomeSettings(true)"); + expect(mainToolbarSource).toContain('actionId === "view.openGridQuantizePanel"'); + expect(mainToolbarSource).toContain("setShowQuantizePanel(true)"); + expect(mainToolbarSource).toContain('actionId === "edit.applyCurrentQuantize"'); + expect(mainToolbarSource).toContain("handleApplyQuantize()"); + + expect(mixerPanelSource).toContain('actionId === "mixer.addMonitorFx"'); + expect(mixerPanelSource).toContain('actionId === "mixer.close"'); + expect(normalizeSourceText(mixerPanelSource)).toContain('registerShortcutSurface(\n context,'); + expect(mixerPanelSource).toContain('activateShortcutContext({ kind: "mixer" })'); + expect(masterTrackHeaderSource).toContain('actionId !== "mixer.openMasterFxChain"'); + expect(channelStripSource).toContain('actionId === "mixer.openMasterFxChain"'); + + for (const actionId of [ + "browser.focusSearch", + "browser.toggleFavorites", + "browser.openUserEffectsFolder", + "browser.toggleScanFolders", + "browser.addScanFolder", + "browser.scanPlugins", + "browser.deepScanPlugins", + "browser.removeCurrentInstrument", + ]) { + expect(pluginBrowserSource).toContain(`actionId === "${actionId}"`); + } + for (const actionId of [ + "browser.mediaNavigateUp", + "browser.mediaToggleRecent", + "browser.mediaFocusFilter", + ]) { + expect(mediaExplorerSource).toContain(`actionId === "${actionId}"`); + } + for (const source of [pluginBrowserSource, mediaExplorerSource]) { + expect(source).toContain('registerShortcutSurface(context, () => "unmatched", fallback)'); + expect(source).toContain('registerScopedActionExecutor('); + expect(source).toContain('activateShortcutContext({ kind: "browser" })'); + } + }); + + it("connects wheel-edited faders to one reusable begin/commit transaction", () => { + expect(channelStripSource).toContain("const beginVolumeEdit = useCallback"); + expect(channelStripSource).toContain("const commitVolumeEdit = useCallback"); + expect(channelStripSource).toContain("const beginPanEdit = useCallback"); + expect(channelStripSource).toContain("const commitPanEdit = useCallback"); + expect(channelStripSource).toContain("onBeginEdit={beginVolumeEdit}"); + expect(channelStripSource).toContain("onCommitEdit={commitVolumeEdit}"); + expect(channelStripSource).toContain("onBeginEdit={beginPanEdit}"); + expect(channelStripSource).toContain("onCommitEdit={commitPanEdit}"); + expect(channelStripSource).toContain("beginMasterVolumeEdit()"); + expect(channelStripSource).toContain("commitMasterVolumeEdit()"); + expect(channelStripSource).toContain("beginMasterPanEdit()"); + expect(channelStripSource).toContain("commitMasterPanEdit()"); + expect(channelStripSource).toContain('gesture.ruleId === "cakewalk-sonar.console-all-faders"'); + expect(channelStripSource).toContain('gesture.ruleId === "cakewalk-sonar.console-selected-faders"'); + expect(channelStripSource).toContain("state.selectedTrackIds"); + expect(channelStripSource).toContain("beginTrackVolumeBatchEdit(targetIds)"); + expect(channelStripSource).toContain("adjustTrackVolumeBatch(deltaDB)"); + expect(channelStripSource).toContain("commitTrackVolumeBatchEdit()"); + expect(channelStripSource).toContain("if (isMaster) return;"); + expect(channelStripSource).toContain("onWheel={handleGroupedVolumeWheel}"); + expect(channelStripSource).not.toContain("handleVolumePointerDown"); + expect(channelStripSource).not.toContain("handlePanPointerDown"); + expect(trackHeaderSource).toContain("onBeginEdit={beginInlineFaderEdit}"); + expect(trackHeaderSource).toContain("onCommitEdit={commitInlineFaderEdit}"); + expect(trackHeaderSource).not.toContain("document.addEventListener(\"pointerup\", commitOnce"); + }); + + it("coalesces continuous AI workflow parameter controls through store edit sessions", () => { + expect(aiTrackSource).toContain("beginAITrackParamsEdit: state.beginAITrackParamsEdit"); + expect(aiTrackSource).toContain("commitAITrackParamsEdit: state.commitAITrackParamsEdit"); + expect(aiTrackSource).toContain("onBeginParamsEdit={() => beginAITrackParamsEdit(track.id)}"); + expect(aiTrackSource).toContain("onCommitParamsEdit={() => commitAITrackParamsEdit(track.id)}"); + expect(aiWorkflowModalSource).toContain("onBeginEdit={onBeginParamsEdit}"); + expect(aiWorkflowModalSource).toContain("onCommitEdit={onCommitParamsEdit}"); + expect(aiWorkflowParamSource).toContain("onBeginEdit={onBeginEdit}"); + expect(aiWorkflowParamSource).toContain("onCommitEdit={onCommitEdit}"); + }); +}); + +describe("scoped executor context ownership", () => { + function register( + context: EditShortcutContext, + execute: (actionId: string) => "handled" | "claimed_noop" | "unmatched", + ) { + cleanups.push(registerScopedActionExecutor(context, execute)); + } + + it("never dispatches an action to a different editor session or surface", () => { + const timeline = vi.fn(() => "handled" as const); + const dockedPiano = vi.fn(() => "handled" as const); + const detachedPiano = vi.fn(() => "handled" as const); + const pitch = vi.fn(() => "handled" as const); + const trackHeader = vi.fn(() => "handled" as const); + const mixer = vi.fn(() => "handled" as const); + const fxPanel = vi.fn(() => "handled" as const); + const detachedPlugin = vi.fn(() => "handled" as const); + + register({ kind: "timeline" }, timeline); + register({ kind: "piano_roll", sessionId: "docked" }, dockedPiano); + register({ kind: "piano_roll", sessionId: "detached" }, detachedPiano); + register({ kind: "pitch_editor" }, pitch); + register({ kind: "track_control_panel" }, trackHeader); + register({ kind: "mixer" }, mixer); + register({ kind: "plugin", sessionId: "fx-chain:track:track-a" }, fxPanel); + register({ kind: "plugin", sessionId: "detached-plugin" }, detachedPlugin); + + const cases: Array<{ + context: EditShortcutContext; + actionId: string; + expected: ReturnType; + }> = [ + { context: { kind: "timeline" }, actionId: "clip.splitAtPointer", expected: timeline }, + { context: { kind: "piano_roll", sessionId: "docked" }, actionId: "midi.noteProperties", expected: dockedPiano }, + { context: { kind: "piano_roll", sessionId: "detached" }, actionId: "midi.configureControllerLanes", expected: detachedPiano }, + { context: { kind: "pitch_editor" }, actionId: "pitch.toggleAB", expected: pitch }, + { context: { kind: "track_control_panel" }, actionId: "track.openSelectedFxChain", expected: trackHeader }, + { context: { kind: "mixer" }, actionId: "track.openSelectedFxChain", expected: mixer }, + { context: { kind: "plugin", sessionId: "fx-chain:track:track-a" }, actionId: "fx.openSelectedEditor", expected: fxPanel }, + { context: { kind: "plugin", sessionId: "detached-plugin" }, actionId: "fx.add", expected: detachedPlugin }, + ]; + + for (const [index, testCase] of cases.entries()) { + activateShortcutContext(testCase.context); + expect(executeActiveScopedAction(testCase.actionId)).toBe("handled"); + for (const executor of [timeline, dockedPiano, detachedPiano, pitch, trackHeader, mixer, fxPanel, detachedPlugin]) { + expect(executor).toHaveBeenCalledTimes(executor === testCase.expected ? 1 : 0); + executor.mockClear(); + } + expect(index).toBeGreaterThanOrEqual(0); + } + }); + + it("does not fall back to another plugin instance when the active session has no executor", () => { + const fxPanel = vi.fn(() => "handled" as const); + register({ kind: "plugin", sessionId: "fx-chain:track:track-a" }, fxPanel); + + activateShortcutContext({ kind: "plugin", sessionId: "detached-plugin" }); + expect(executeActiveScopedAction("fx.removeSelected")).toBe("unmatched"); + expect(fxPanel).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/contextWheelBehaviors.test.ts b/frontend/src/__tests__/contextWheelBehaviors.test.ts new file mode 100644 index 0000000..5a74eae --- /dev/null +++ b/frontend/src/__tests__/contextWheelBehaviors.test.ts @@ -0,0 +1,367 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + computeAnchoredVerticalWheelZoom, + computeSpectrogramBandGeometry, + computeWheelResizedSize, + createWheelEditBurstController, + DEFAULT_TIMELINE_VERTICAL_SCALE_VIEW, + getAccumulatedWheelNudgeDirection, + getAccumulatedWheelStepCount, + getMidiNoteHeightZoomPointerOffset, + getTimelineHorizontalScrollMax, + getTimelineVerticalScaleSubtarget, + getTimelineVisibleContentEnd, + getWheelNudgeDirection, + getWheelStepCount, + updateTimelineVerticalScaleView, +} from "../utils/contextWheelBehaviors"; +import { createWheelDeltaAccumulator } from "../utils/wheelDeltaAccumulator"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("context-specific wheel behavior helpers", () => { + it.each([ + { amount: -100, expected: 1 }, + { amount: 100, expected: -1 }, + { amount: -250, expected: 2.5 }, + { amount: -1, expected: 0.01 }, + { amount: 0, expected: 0 }, + { amount: Number.NaN, expected: 0 }, + ])("converts $amount to $expected parameter steps", ({ amount, expected }) => { + expect(getWheelStepCount(amount)).toBe(expected); + }); + + it("accumulates fractional context steps and discrete nudges per target", () => { + const smooth = createWheelDeltaAccumulator({ quantum: 1 }); + expect(getAccumulatedWheelStepCount(smooth, "fade:a", -0.5)).toBe(0); + expect(getAccumulatedWheelStepCount(smooth, "fade:a", -0.5)).toBe(0.01); + expect(getAccumulatedWheelStepCount(smooth, "fade:b", -0.5)).toBe(0); + + const discrete = createWheelDeltaAccumulator({ quantum: 100 }); + expect(getAccumulatedWheelNudgeDirection(discrete, "clip:a", 25)).toBe(0); + expect(getAccumulatedWheelNudgeDirection(discrete, "clip:a", 25)).toBe(0); + expect(getAccumulatedWheelNudgeDirection(discrete, "clip:a", 25)).toBe(0); + expect(getAccumulatedWheelNudgeDirection(discrete, "clip:a", 25)).toBe(1); + smooth.dispose(); + discrete.dispose(); + }); + + it("isolates accumulated edit bursts across target, direction, and disposal", () => { + const events: string[] = []; + const controller = createWheelEditBurstController({ + idleMs: 180, + getKey: (target: { id: string }) => target.id, + onBegin: (target) => events.push(`begin:${target.id}`), + onCommit: (target) => events.push(`commit:${target.id}`), + }); + const accumulator = createWheelDeltaAccumulator({ + quantum: 100, + idleMs: 180, + onReset: ({ hadOutput }) => { + if (hadOutput) controller.commit(); + }, + }); + const dispatchPacket = (id: string, amount: number) => { + const direction = getAccumulatedWheelNudgeDirection( + accumulator, + `note-nudge:${id}`, + amount, + ); + if (direction === 0) { + const pending = controller.getActiveTarget(); + if (pending?.id === id) controller.touch(pending); + return; + } + controller.touch({ id }); + events.push(`output:${id}:${direction}`); + }; + + dispatchPacket("a", 100); + dispatchPacket("a", 25); + dispatchPacket("b", 75); + dispatchPacket("b", 25); + dispatchPacket("b", -25); + dispatchPacket("b", -75); + accumulator.dispose(); + controller.dispose(); + + expect(events).toEqual([ + "begin:a", + "output:a:1", + "commit:a", + "begin:b", + "output:b:1", + "commit:b", + "begin:b", + "output:b:-1", + "commit:b", + ]); + }); + + it.each([ + { amount: -120, expected: -1 }, + { amount: 120, expected: 1 }, + { amount: 0, expected: 0 }, + { amount: Number.POSITIVE_INFINITY, expected: 0 }, + ] as const)("maps $amount to nudge direction $expected", ({ amount, expected }) => { + expect(getWheelNudgeDirection(amount)).toBe(expected); + }); + + it("keeps the MIDI row under the pointer stable while changing note height", () => { + const before = { itemHeight: 12, scrollOffset: 480, pointerOffset: 120 }; + const rowAtPointer = (before.scrollOffset + before.pointerOffset) / before.itemHeight; + const result = computeAnchoredVerticalWheelZoom({ + ...before, + amount: -120, + minItemHeight: 6, + maxItemHeight: 36, + maxScrollOffset: 4_000, + }); + + expect(result.itemHeight).toBeGreaterThan(before.itemHeight); + expect((result.scrollOffset + before.pointerOffset) / result.itemHeight) + .toBeCloseTo(rowAtPointer, 10); + }); + + it.each([ + { amount: -100_000, expected: 36 }, + { amount: 100_000, expected: 6 }, + ])("clamps extreme note-height zoom to $expected", ({ amount, expected }) => { + const result = computeAnchoredVerticalWheelZoom({ + itemHeight: 12, + scrollOffset: 0, + pointerOffset: 0, + amount, + minItemHeight: 6, + maxItemHeight: 36, + maxScrollOffset: 4_000, + }); + expect(result.itemHeight).toBe(expected); + }); + + it.each([ + { target: "grid" as const, pointer: 0 }, + { target: "grid" as const, pointer: 120 }, + { target: "grid" as const, pointer: 240 }, + { target: "keyboard" as const, pointer: 0 }, + { target: "keyboard" as const, pointer: 120 }, + { target: "keyboard" as const, pointer: 240 }, + ])("anchors $target MIDI note-height zoom at pointer Y $pointer", ({ target, pointer }) => { + const pointerOffset = getMidiNoteHeightZoomPointerOffset({ + target, + stagePointerOffset: target === "grid" ? pointer : undefined, + keyboardPointerOffset: target === "keyboard" ? pointer : undefined, + gridHeight: 240, + }); + const rowBefore = (480 + pointerOffset) / 12; + const result = computeAnchoredVerticalWheelZoom({ + itemHeight: 12, + scrollOffset: 480, + pointerOffset, + amount: -120, + minItemHeight: 6, + maxItemHeight: 36, + maxScrollOffset: 4_000, + }); + + expect(pointerOffset).toBe(pointer); + expect((result.scrollOffset + pointerOffset) / result.itemHeight) + .toBeCloseTo(rowBefore, 10); + }); + + it.each([ + { target: "grid" as const, stagePointerOffset: -50, keyboardPointerOffset: undefined, expected: 0 }, + { target: "grid" as const, stagePointerOffset: 500, keyboardPointerOffset: undefined, expected: 240 }, + { target: "keyboard" as const, stagePointerOffset: undefined, keyboardPointerOffset: -50, expected: 0 }, + { target: "keyboard" as const, stagePointerOffset: undefined, keyboardPointerOffset: 500, expected: 240 }, + ])("clamps $target pointer coordinates to the note grid", (entry) => { + expect(getMidiNoteHeightZoomPointerOffset({ + ...entry, + gridHeight: 240, + })).toBe(entry.expected); + }); + + it.each([ + { amount: -120, relation: "grow" }, + { amount: 120, relation: "shrink" }, + { amount: 0, relation: "same" }, + ])("resizes a hovered lane in the expected direction for $amount", ({ amount, relation }) => { + const result = computeWheelResizedSize({ + currentSize: 60, + amount, + minSize: 24, + maxSize: 240, + }); + if (relation === "grow") expect(result).toBeGreaterThan(60); + if (relation === "shrink") expect(result).toBeLessThan(60); + if (relation === "same") expect(result).toBe(60); + }); + + it.each([ + { amount: -100_000, expected: 240 }, + { amount: 100_000, expected: 24 }, + { amount: Number.NaN, expected: 60 }, + ])("clamps or sanitizes lane resizing for $amount", ({ amount, expected }) => { + expect(computeWheelResizedSize({ + currentSize: 60, + amount, + minSize: 24, + maxSize: 240, + })).toBe(expected); + }); + + it("uses MIDI clips when calculating horizontal Timeline wheel extent", () => { + expect(getTimelineVisibleContentEnd([ + { + clips: [{ startTime: 1, duration: 2 }], + midiClips: [{ startTime: 12, duration: 4 }], + }, + ])).toBe(16); + expect(getTimelineVisibleContentEnd([ + { + clips: [], + midiClips: [{ startTime: 20, duration: 3 }], + }, + ])).toBe(23); + }); + + it("includes active recording extent and ignores malformed clip geometry", () => { + expect(getTimelineVisibleContentEnd([ + { + clips: [ + { startTime: Number.NaN, duration: 10 }, + { startTime: 8, duration: -4 }, + ], + midiClips: [{ startTime: 2, duration: Number.POSITIVE_INFINITY }], + }, + ], 14)).toBe(14); + }); + + it("shares one finite horizontal scroll extent between Timeline content and ruler", () => { + const tracks = [{ + clips: [{ startTime: 4, duration: 2 }], + midiClips: [{ startTime: 20, duration: 5 }], + }]; + expect(getTimelineHorizontalScrollMax(tracks, 30, 10, 500)).toBe(2_800); + expect(getTimelineHorizontalScrollMax(tracks, undefined, 10, 500)).toBe(2_750); + expect(getTimelineHorizontalScrollMax(tracks, undefined, Number.NaN, 500)).toBe(0); + }); + + it.each([ + { stageX: -0.01, expected: undefined }, + { stageX: 0, expected: "spectrogram_scale" }, + { stageX: 27.99, expected: "spectrogram_scale" }, + { stageX: 28, expected: undefined }, + ])("limits the spectrogram scale target to its exact strip at x=$stageX", ({ stageX, expected }) => { + expect(getTimelineVerticalScaleSubtarget({ + stageX, + scaleStripWidth: 28, + trackType: "audio", + spectralView: true, + })).toBe(expected); + }); + + it("keeps spectrogram zoom, pan, and dB floor as independent coexisting view state", () => { + const zoomed = updateTimelineVerticalScaleView( + DEFAULT_TIMELINE_VERTICAL_SCALE_VIEW, + "zoom", + -120, + ); + const panned = updateTimelineVerticalScaleView(zoomed, "pan", 100); + const adjusted = updateTimelineVerticalScaleView(panned, "db-floor", -200); + + expect(adjusted.spectrogramScale).toBeGreaterThan(1); + expect(adjusted.verticalOffset).toBeCloseTo(0.2); + expect(adjusted.spectrogramDbFloor).toBe(-70); + }); + + it("clamps every spectrogram scale-strip view dimension", () => { + const zoomedIn = updateTimelineVerticalScaleView( + DEFAULT_TIMELINE_VERTICAL_SCALE_VIEW, + "zoom", + -100_000, + ); + const zoomedOut = updateTimelineVerticalScaleView(zoomedIn, "zoom", 100_000); + const panned = updateTimelineVerticalScaleView(zoomedOut, "pan", 100_000); + const floored = updateTimelineVerticalScaleView(panned, "db-floor", -100_000); + + expect(zoomedIn.spectrogramScale).toBe(8); + expect(zoomedOut.spectrogramScale).toBe(0.25); + expect(panned.verticalOffset).toBe(1); + expect(floored.spectrogramDbFloor).toBe(-120); + }); + + it("changes rendered spectrogram band geometry when its scale changes", () => { + const normal = computeSpectrogramBandGeometry({ + height: 80, + bandCount: 8, + scale: 1, + verticalOffset: 0, + }); + const zoomed = computeSpectrogramBandGeometry({ + height: 80, + bandCount: 8, + scale: 2, + verticalOffset: 0, + }); + const panned = computeSpectrogramBandGeometry({ + height: 80, + bandCount: 8, + scale: 1, + verticalOffset: 0.5, + }); + + expect(normal[3]).toEqual({ bandIndex: 3, y: 40, height: 10 }); + expect(zoomed[3]).toEqual({ bandIndex: 3, y: 40, height: 20 }); + expect(panned[3].y).toBe(50); + expect(zoomed).not.toEqual(normal); + }); + + it("groups one target's wheel burst and commits it after the full idle delay", () => { + vi.useFakeTimers(); + const events: string[] = []; + const controller = createWheelEditBurstController({ + idleMs: 180, + getKey: (target: { id: string }) => target.id, + onBegin: (target) => events.push(`begin:${target.id}`), + onCommit: (target) => events.push(`commit:${target.id}`), + }); + + controller.touch({ id: "lane-a" }); + vi.advanceTimersByTime(100); + controller.touch({ id: "lane-a" }); + expect(events).toEqual(["begin:lane-a"]); + + vi.advanceTimersByTime(179); + expect(events).toEqual(["begin:lane-a"]); + vi.advanceTimersByTime(1); + expect(events).toEqual(["begin:lane-a", "commit:lane-a"]); + expect(controller.getActiveTarget()).toBeNull(); + }); + + it("commits when switching targets and when the owning UI is disposed", () => { + vi.useFakeTimers(); + const events: string[] = []; + const controller = createWheelEditBurstController({ + idleMs: 180, + getKey: (target: { id: string }) => target.id, + onBegin: (target) => events.push(`begin:${target.id}`), + onCommit: (target) => events.push(`commit:${target.id}`), + }); + + controller.touch({ id: "lane-a" }); + controller.touch({ id: "lane-b" }); + controller.dispose(); + + expect(events).toEqual([ + "begin:lane-a", + "commit:lane-a", + "begin:lane-b", + "commit:lane-b", + ]); + vi.runAllTimers(); + expect(events).toHaveLength(4); + }); +}); diff --git a/frontend/src/__tests__/contextWheelStoreMutations.test.ts b/frontend/src/__tests__/contextWheelStoreMutations.test.ts new file mode 100644 index 0000000..3e83f68 --- /dev/null +++ b/frontend/src/__tests__/contextWheelStoreMutations.test.ts @@ -0,0 +1,587 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + getEffectiveTrackHeight, + getTrackAtY, + getTrackYPositions, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; +import { + createWheelEditBurstController, + getAccumulatedWheelNudgeDirection, +} from "../utils/contextWheelBehaviors"; +import { createWheelDeltaAccumulator } from "../utils/wheelDeltaAccumulator"; + +const originalState = useDAWStore.getState(); + +function audioClip(id: string, startTime: number, locked = false): AudioClip { + return { + id, + name: id, + filePath: `C:/audio/${id}.wav`, + startTime, + duration: 2, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + locked, + }; +} + +function midiClip(id: string, startTime: number, locked = false): MIDIClip { + return { + id, + name: id, + startTime, + duration: 2, + offset: 0, + sourceStart: 0, + sourceLength: 2, + loopEnabled: false, + loopOffset: 0, + loopLength: 2, + events: [ + { timestamp: 0.5, type: "noteOn", note: 60, velocity: 90 }, + { timestamp: 1, type: "noteOff", note: 60, velocity: 0 }, + ], + ccEvents: [], + color: "#f72585", + locked, + }; +} + +beforeEach(() => { + commandManager.clear(); + const audioTrack = createDefaultTrack("audio", "Audio", "#38bdf8", "audio"); + audioTrack.clips = [audioClip("audio-clip", 1), audioClip("locked-audio", 2, true)]; + const midiTrack = createDefaultTrack("midi", "MIDI", "#f72585", "midi"); + midiTrack.midiClips = [midiClip("midi-clip", 1), midiClip("locked-midi", 2, true)]; + useDAWStore.setState({ + tracks: [audioTrack, midiTrack], + selectedClipId: null, + selectedClipIds: [], + canUndo: false, + canRedo: false, + syncMIDITrackToBackend: vi.fn().mockResolvedValue(undefined), + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("wheel-triggered store mutations", () => { + it("groups FL Playlist track reorder packets into one exact-order command", () => { + const thirdTrack = createDefaultTrack("audio-2", "Audio 2", "#22c55e", "audio"); + useDAWStore.setState((state) => ({ tracks: [...state.tracks, thirdTrack] })); + const state = useDAWStore.getState(); + + state.beginTrackReorderEdit("audio"); + expect(state.previewTrackReorder("audio", 1)).toBe(true); + expect(state.previewTrackReorder("audio", 1)).toBe(true); + state.commitTrackReorderEdit("audio"); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "midi", + "audio-2", + "audio", + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "audio", + "midi", + "audio-2", + ]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "midi", + "audio-2", + "audio", + ]); + }); + + it("accumulates precision Playlist packets into native steps and one undo burst", () => { + vi.useFakeTimers(); + const thirdTrack = createDefaultTrack("audio-2", "Audio 2", "#22c55e", "audio"); + useDAWStore.setState((state) => ({ tracks: [...state.tracks, thirdTrack] })); + const target = { kind: "track" as const, id: "audio" }; + const controller = createWheelEditBurstController({ + idleMs: 180, + getKey: (value: typeof target) => `${value.kind}:${value.id}`, + onBegin: (value) => useDAWStore.getState().beginTrackReorderEdit(value.id), + onCommit: (value) => useDAWStore.getState().commitTrackReorderEdit(value.id), + }); + const accumulator = createWheelDeltaAccumulator({ + quantum: 100, + idleMs: 180, + onReset: ({ hadOutput }) => { + if (hadOutput) controller.commit(); + }, + }); + const dispatchPacket = (amount: number) => { + const direction = getAccumulatedWheelNudgeDirection( + accumulator, + `track-reorder:${target.id}`, + amount, + ); + if (direction === 0) { + const pending = controller.getActiveTarget(); + if (pending) controller.touch(pending); + return; + } + controller.touch(target); + expect(useDAWStore.getState().previewTrackReorder(target.id, direction)).toBe(true); + }; + + dispatchPacket(25); + dispatchPacket(25); + dispatchPacket(25); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "audio", + "midi", + "audio-2", + ]); + dispatchPacket(25); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "midi", + "audio", + "audio-2", + ]); + + vi.advanceTimersByTime(100); + dispatchPacket(25); + vi.advanceTimersByTime(100); + dispatchPacket(25); + dispatchPacket(25); + dispatchPacket(25); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "midi", + "audio-2", + "audio", + ]); + expect(commandManager.getUndoStack()).toHaveLength(0); + + vi.advanceTimersByTime(179); + expect(commandManager.getUndoStack()).toHaveLength(0); + vi.advanceTimersByTime(1); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "audio", + "midi", + "audio-2", + ]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "midi", + "audio-2", + "audio", + ]); + + accumulator.dispose(); + controller.dispose(); + }); + + it("rejects invalid and boundary-only Playlist reorder previews without history", () => { + const state = useDAWStore.getState(); + expect(state.previewTrackReorder("audio", 1)).toBe(false); + state.beginTrackReorderEdit("missing-track"); + expect(state.previewTrackReorder("missing-track", 1)).toBe(false); + state.commitTrackReorderEdit("missing-track"); + state.beginTrackReorderEdit("audio"); + expect(state.previewTrackReorder("audio", -1)).toBe(false); + state.commitTrackReorderEdit("audio"); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["audio", "midi"]); + }); + + it("groups FL clip nudge packets and restores the exact pre-gesture selection", () => { + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id !== "audio" + ? track + : { + ...track, + clips: track.clips.map((clip) => ({ ...clip, groupId: "group-a" })), + }), + selectedClipId: "midi-clip", + selectedClipIds: ["midi-clip"], + })); + const state = useDAWStore.getState(); + state.beginClipNudgeEdit("audio-clip"); + expect(useDAWStore.getState().selectedClipIds).toEqual(["audio-clip", "locked-audio"]); + expect(state.previewClipNudge("audio-clip", "right", true)).toBe(true); + expect(state.previewClipNudge("audio-clip", "right", true)).toBe(true); + state.commitClipNudgeEdit("audio-clip"); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.startTime)).toEqual([1.02, 2]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.startTime)).toEqual([1, 2]); + expect(useDAWStore.getState().selectedClipIds).toEqual(["midi-clip"]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.startTime)).toEqual([1.02, 2]); + expect(useDAWStore.getState().selectedClipIds).toEqual(["audio-clip", "locked-audio"]); + }); + + it("rejects invalid, locked, and boundary-only clip nudge sessions", () => { + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => ({ + ...track, + clips: track.clips.map((clip) => clip.id === "audio-clip" + ? { ...clip, startTime: 0 } + : clip), + })), + })); + const state = useDAWStore.getState(); + expect(state.previewClipNudge("audio-clip", "right", true)).toBe(false); + state.beginClipNudgeEdit("missing-clip"); + state.commitClipNudgeEdit("missing-clip"); + state.beginClipNudgeEdit("locked-audio"); + state.commitClipNudgeEdit("locked-audio"); + state.beginClipNudgeEdit("audio-clip"); + expect(state.previewClipNudge("audio-clip", "left", true)).toBe(false); + state.commitClipNudgeEdit("audio-clip"); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(0); + }); + + it("restores surviving clip previews when a nudge target disappears mid-burst", () => { + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id !== "audio" + ? track + : { + ...track, + clips: track.clips.map((clip) => ({ ...clip, groupId: "group-a", locked: false })), + }), + })); + const state = useDAWStore.getState(); + state.beginClipNudgeEdit("audio-clip"); + expect(state.previewClipNudge("audio-clip", "right", true)).toBe(true); + useDAWStore.setState((current) => ({ + tracks: current.tracks.map((track) => track.id !== "audio" + ? track + : { ...track, clips: track.clips.filter((clip) => clip.id !== "locked-audio") }), + })); + + state.commitClipNudgeEdit("audio-clip"); + + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(1); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("commits exact FL wheel transactions on target switch and owner disposal", () => { + const thirdTrack = createDefaultTrack("audio-2", "Audio 2", "#22c55e", "audio"); + useDAWStore.setState((state) => ({ tracks: [...state.tracks, thirdTrack] })); + type Target = + | { kind: "track"; id: string } + | { kind: "clip"; id: string }; + const controller = createWheelEditBurstController({ + idleMs: 180, + getKey: (target) => `${target.kind}:${target.id}`, + onBegin: (target) => { + const state = useDAWStore.getState(); + if (target.kind === "track") state.beginTrackReorderEdit(target.id); + else state.beginClipNudgeEdit(target.id); + }, + onCommit: (target) => { + const state = useDAWStore.getState(); + if (target.kind === "track") state.commitTrackReorderEdit(target.id); + else state.commitClipNudgeEdit(target.id); + }, + }); + + controller.touch({ kind: "track", id: "audio" }); + expect(useDAWStore.getState().previewTrackReorder("audio", 1)).toBe(true); + controller.touch({ kind: "clip", id: "audio-clip" }); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().previewClipNudge("audio-clip", "right", true)).toBe(true); + controller.dispose(); + expect(commandManager.getUndoStack()).toHaveLength(2); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks + .flatMap((track) => track.clips) + .find((clip) => clip.id === "audio-clip")?.startTime).toBe(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "audio", + "midi", + "audio-2", + ]); + useDAWStore.getState().redo(); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual([ + "midi", + "audio", + "audio-2", + ]); + expect(useDAWStore.getState().tracks + .flatMap((track) => track.clips) + .find((clip) => clip.id === "audio-clip")?.startTime).toBe(1.01); + }); + + it("nudges selected audio and MIDI clips in one undoable transaction", () => { + useDAWStore.setState({ + selectedClipIds: ["audio-clip", "midi-clip", "locked-audio", "locked-midi"], + }); + + useDAWStore.getState().nudgeClips("right", true); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.startTime)).toEqual([1.01, 2]); + expect(useDAWStore.getState().tracks[1].midiClips.map((clip) => clip.startTime)).toEqual([1.01, 2]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(1); + expect(useDAWStore.getState().tracks[1].midiClips[0].startTime).toBe(1); + + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(1.01); + expect(useDAWStore.getState().tracks[1].midiClips[0].startTime).toBe(1.01); + }); + + it("does not create history for a locked or left-boundary-only nudge", () => { + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => ({ + ...track, + clips: track.clips.map((clip) => clip.id === "audio-clip" ? { ...clip, startTime: 0 } : clip), + })), + selectedClipIds: ["audio-clip", "locked-audio"], + })); + + useDAWStore.getState().nudgeClips("left", true); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.startTime)).toEqual([0, 2]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("commits Cubase-style fade and event-volume wheel edits as undoable actions", () => { + const state = useDAWStore.getState(); + state.beginClipFadeEdit("audio-clip"); + state.previewClipFades("audio-clip", 0.1, 0); + state.previewClipFades("audio-clip", 0.25, 0); + expect(commandManager.getUndoStack()).toHaveLength(0); + state.commitClipFadeEdit("audio-clip"); + expect(useDAWStore.getState().tracks[0].clips[0].fadeIn).toBe(0.25); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].fadeIn).toBe(0); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips[0].fadeIn).toBe(0.25); + + commandManager.clear(); + state.beginClipVolumeEdit("audio-clip"); + state.setClipVolume("audio-clip", 0.5); + state.setClipVolume("audio-clip", 1); + state.commitClipVolumeEdit("audio-clip"); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].clips[0].volumeDB).toBe(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].volumeDB).toBe(0); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips[0].volumeDB).toBe(1); + }); + + it("commits a multi-packet Cubase fade burst only after the idle timeout", () => { + vi.useFakeTimers(); + const controller = createWheelEditBurstController({ + idleMs: 180, + getKey: (clipId: string) => clipId, + onBegin: (clipId) => useDAWStore.getState().beginClipFadeEdit(clipId), + onCommit: (clipId) => useDAWStore.getState().commitClipFadeEdit(clipId), + }); + + controller.touch("audio-clip"); + useDAWStore.getState().previewClipFades("audio-clip", 0.1, 0); + vi.advanceTimersByTime(100); + controller.touch("audio-clip"); + useDAWStore.getState().previewClipFades("audio-clip", 0.25, 0); + vi.advanceTimersByTime(179); + expect(commandManager.getUndoStack()).toHaveLength(0); + vi.advanceTimersByTime(1); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].clips[0].fadeIn).toBe(0.25); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].fadeIn).toBe(0); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips[0].fadeIn).toBe(0.25); + }); + + it("guards fade previews without a valid transaction and skips no-op history", () => { + const state = useDAWStore.getState(); + state.previewClipFades("audio-clip", 0.5, 0); + expect(useDAWStore.getState().tracks[0].clips[0].fadeIn).toBe(0); + + state.beginClipFadeEdit("missing-clip"); + state.previewClipFades("missing-clip", 0.5, 0); + state.commitClipFadeEdit("missing-clip"); + state.beginClipFadeEdit("audio-clip"); + state.previewClipFades("audio-clip", 0, 0); + state.commitClipFadeEdit("audio-clip"); + expect(commandManager.getUndoStack()).toHaveLength(0); + + state.beginClipFadeEdit("audio-clip"); + state.previewClipFades("audio-clip", 0.5, 0); + state.cancelClipFadeEdit("audio-clip"); + expect(useDAWStore.getState().tracks[0].clips[0].fadeIn).toBe(0); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("commits a hovered MIDI note property edit and restores it on undo", () => { + const oldEvents = useDAWStore.getState().tracks[1].midiClips[0].events.map((event) => ({ ...event })); + const firstPreview = oldEvents.map((event) => event.type === "noteOn" + ? { ...event, velocity: 91 } + : event); + const nextEvents = firstPreview.map((event) => event.type === "noteOn" + ? { ...event, velocity: 92 } + : event); + + useDAWStore.getState().previewMIDIClipEvents("midi", "midi-clip", firstPreview); + useDAWStore.getState().previewMIDIClipEvents("midi", "midi-clip", nextEvents); + useDAWStore.getState().commitMIDIClipEvents( + "midi", + "midi-clip", + oldEvents, + nextEvents, + "Adjust MIDI note velocity", + ); + expect(useDAWStore.getState().tracks[1].midiClips[0].events[0].velocity).toBe(92); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[1].midiClips[0].events[0].velocity).toBe(90); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[1].midiClips[0].events[0].velocity).toBe(92); + }); + + it("commits a multi-packet MIDI note nudge as one undo command", () => { + const oldEvents = useDAWStore.getState().tracks[1].midiClips[0].events.map((event) => ({ ...event })); + const firstPreview = oldEvents.map((event) => ({ + ...event, + timestamp: event.timestamp + 0.01, + })); + const finalEvents = firstPreview.map((event) => ({ + ...event, + timestamp: event.timestamp + 0.01, + })); + + useDAWStore.getState().previewMIDIClipEvents("midi", "midi-clip", firstPreview); + useDAWStore.getState().previewMIDIClipEvents("midi", "midi-clip", finalEvents); + expect(commandManager.getUndoStack()).toHaveLength(0); + useDAWStore.getState().commitMIDIClipEvents( + "midi", + "midi-clip", + oldEvents, + finalEvents, + "Nudge MIDI note", + ); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks[1].midiClips[0].events[0].timestamp).toBeCloseTo(0.52); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[1].midiClips[0].events[0].timestamp).toBe(0.5); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[1].midiClips[0].events[0].timestamp).toBeCloseTo(0.52); + }); + + it("commits one automation-lane resize transaction with undo and redo", () => { + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track, index) => index !== 0 + ? track + : { + ...track, + showAutomation: true, + automationLanes: [{ + id: "volume-lane", + param: "volume", + points: [], + visible: true, + mode: "read" as const, + armed: false, + readEnabled: true, + }], + }), + })); + + const state = useDAWStore.getState(); + state.setAutomationLaneHeight("audio", "volume-lane", 96); + expect(useDAWStore.getState().tracks[0].automationLanes[0].height).toBeUndefined(); + + state.beginAutomationLaneHeightEdit("audio", "volume-lane"); + state.setAutomationLaneHeight("audio", "volume-lane", 80); + state.setAutomationLaneHeight("audio", "volume-lane", 96); + expect(commandManager.getUndoStack()).toHaveLength(0); + state.commitAutomationLaneHeightEdit("audio", "volume-lane"); + expect(commandManager.getUndoStack()).toHaveLength(1); + + let tracks = useDAWStore.getState().tracks; + expect(tracks[0].automationLanes[0].height).toBe(96); + expect(getEffectiveTrackHeight(tracks[0], 100)).toBe(196); + + const { trackYs } = getTrackYPositions(tracks, 100); + expect(trackYs[1]).toBe(196); + expect(getTrackAtY(170, tracks, trackYs, 100)).toEqual({ + trackIndex: 0, + isInClipArea: false, + laneIndex: 0, + }); + expect(getTrackAtY(197, tracks, trackYs, 100)).toEqual({ + trackIndex: 1, + isInClipArea: true, + laneIndex: -1, + }); + + useDAWStore.getState().undo(); + tracks = useDAWStore.getState().tracks; + expect(tracks[0].automationLanes[0].height).toBeUndefined(); + expect(getEffectiveTrackHeight(tracks[0], 100)).toBe(160); + + useDAWStore.getState().redo(); + tracks = useDAWStore.getState().tracks; + expect(tracks[0].automationLanes[0].height).toBe(96); + expect(getEffectiveTrackHeight(tracks[0], 100)).toBe(196); + }); + + it("does not create lane-height history for a no-op or invalid target", () => { + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track, index) => index !== 0 + ? track + : { + ...track, + showAutomation: true, + automationLanes: [{ + id: "volume-lane", + param: "volume", + points: [], + visible: true, + mode: "read" as const, + armed: false, + readEnabled: true, + }], + }), + })); + + const state = useDAWStore.getState(); + state.beginAutomationLaneHeightEdit("audio", "volume-lane"); + state.setAutomationLaneHeight("audio", "volume-lane", 60); + state.commitAutomationLaneHeightEdit("audio", "volume-lane"); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks[0].automationLanes[0].height).toBeUndefined(); + + state.beginAutomationLaneHeightEdit("missing-track", "volume-lane"); + state.setAutomationLaneHeight("missing-track", "volume-lane", 96); + state.commitAutomationLaneHeightEdit("missing-track", "volume-lane"); + state.beginAutomationLaneHeightEdit("audio", "missing-lane"); + state.setAutomationLaneHeight("audio", "missing-lane", 96); + state.commitAutomationLaneHeightEdit("audio", "missing-lane"); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks[0].automationLanes).toHaveLength(1); + }); +}); diff --git a/frontend/src/__tests__/crossPlatformControlGestures.test.ts b/frontend/src/__tests__/crossPlatformControlGestures.test.ts new file mode 100644 index 0000000..42248de --- /dev/null +++ b/frontend/src/__tests__/crossPlatformControlGestures.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import scriptEditorSource from "../components/ScriptEditor.tsx?raw"; +import timelineSource from "../components/Timeline.tsx?raw"; +import sliderSource from "../components/ui/Slider/Slider.tsx?raw"; +import { matchesActionShortcut } from "../utils/globalShortcutDispatcher"; +import { useDAWStore } from "../store/useDAWStore"; + +describe("cross-platform component gestures", () => { + it("runs scripts with the platform primary modifier", () => { + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: {}, + }); + expect(matchesActionShortcut( + { key: "Enter", code: "Enter", ctrlKey: true }, + "script.runCurrent", + "windows", + )).toBe(true); + expect(matchesActionShortcut( + { key: "Enter", code: "Enter", metaKey: true }, + "script.runCurrent", + "macos", + )).toBe(true); + expect(scriptEditorSource).toContain("matchesActionShortcut(e.nativeEvent, actionId)"); + }); + + it("resets both native and custom sliders with Ctrl or Command", () => { + expect(sliderSource.match(/e\.ctrlKey \|\| e\.metaKey/g)).toHaveLength(2); + }); + + it("lets both Ctrl and Command bypass snap for timeline split/context actions", () => { + expect(timelineSource).not.toContain("ctrlBypass: Boolean(e.evt?.ctrlKey),"); + expect(timelineSource.match(/Boolean\(e\.evt\?\.ctrlKey \|\| e\.evt\?\.metaKey\)/g)?.length) + .toBeGreaterThanOrEqual(5); + }); +}); diff --git a/frontend/src/__tests__/customKeyboardProfileStore.test.ts b/frontend/src/__tests__/customKeyboardProfileStore.test.ts new file mode 100644 index 0000000..48e1b95 --- /dev/null +++ b/frontend/src/__tests__/customKeyboardProfileStore.test.ts @@ -0,0 +1,389 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDAWStore } from "../store/useDAWStore"; +import { + CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY, + MAX_CUSTOM_KEYBOARD_PROFILES, + MAX_CUSTOM_SHORTCUT_ACTIONS_PER_PROFILE, + MAX_CUSTOM_SHORTCUT_BINDINGS_PER_TARGET, + parsePersistedCustomKeyboardProfiles, + type CustomKeyboardShortcutProfile, + type CustomShortcutMap, +} from "../utils/customShortcutProfiles"; + +const INPUT_PROFILE_SETTINGS_KEY = "openstudio.inputProfiles.v1"; + +describe("custom keyboard profile store", () => { + let storage: Storage; + let previousState: Pick< + ReturnType, + | "customShortcuts" + | "customKeyboardProfiles" + | "activeCustomKeyboardProfileId" + | "keyboardShortcutProfileId" + | "mouseBehaviorProfileId" + | "inputProfileOnboardingSeen" + >; + + beforeEach(() => { + const values = new Map(); + storage = { + get length() { return values.size; }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => { values.delete(key); }, + setItem: (key, value) => { values.set(key, String(value)); }, + }; + vi.stubGlobal("localStorage", storage); + const state = useDAWStore.getState(); + previousState = { + customShortcuts: state.customShortcuts, + customKeyboardProfiles: state.customKeyboardProfiles, + activeCustomKeyboardProfileId: state.activeCustomKeyboardProfileId, + keyboardShortcutProfileId: state.keyboardShortcutProfileId, + mouseBehaviorProfileId: state.mouseBehaviorProfileId, + inputProfileOnboardingSeen: state.inputProfileOnboardingSeen, + }; + useDAWStore.setState({ + customShortcuts: {}, + customKeyboardProfiles: [], + activeCustomKeyboardProfileId: null, + keyboardShortcutProfileId: "reaper", + mouseBehaviorProfileId: "logic_pro", + inputProfileOnboardingSeen: true, + }); + }); + + afterEach(() => { + useDAWStore.setState(previousState); + vi.unstubAllGlobals(); + }); + + it("creates a named overlay automatically and persists multi-platform binding lists", () => { + const state = useDAWStore.getState(); + state.setCustomShortcutBindings("transport.record", ["Ctrl+R", "F12"], "common"); + useDAWStore.getState().setCustomShortcutBindings("transport.record", [], "windows"); + + const updated = useDAWStore.getState(); + expect(updated.activeCustomKeyboardProfileId).toBeTruthy(); + expect(updated.customShortcuts["transport.record"]).toEqual({ + common: ["Ctrl+R", "F12"], + windows: [], + }); + const persisted = JSON.parse(storage.getItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY) ?? "{}"); + expect(persisted).toMatchObject({ + schemaVersion: 2, + activeProfileId: updated.activeCustomKeyboardProfileId, + profiles: [{ + baseProfileId: "reaper", + bindings: updated.customShortcuts, + }], + }); + }); + + it("creates, duplicates independently, renames uniquely, activates, and deletes profiles", () => { + const firstId = useDAWStore.getState().createCustomKeyboardProfile("Editing"); + useDAWStore.getState().addCustomShortcutBinding("edit.copy", "Ctrl+C"); + const copyId = useDAWStore.getState().duplicateKeyboardProfile("Editing"); + expect(firstId).toBeTruthy(); + expect(copyId).toBeTruthy(); + if (!firstId || !copyId) return; + useDAWStore.getState().addCustomShortcutBinding("edit.copy", "F8"); + + const profiles = useDAWStore.getState().customKeyboardProfiles; + expect(profiles.find((profile) => profile.id === firstId)?.name).toBe("Editing"); + expect(profiles.find((profile) => profile.id === copyId)?.name).toBe("Editing 2"); + expect(profiles.find((profile) => profile.id === firstId)?.bindings["edit.copy"]) + .toEqual({ common: ["Ctrl+C"] }); + expect(profiles.find((profile) => profile.id === copyId)?.bindings["edit.copy"]) + .toEqual({ common: ["Ctrl+C", "F8"] }); + + expect(useDAWStore.getState().renameCustomKeyboardProfile(copyId, "Keys")).toBe(true); + expect(useDAWStore.getState().activateCustomKeyboardProfile(firstId)).toBe(true); + expect(useDAWStore.getState().customShortcuts["edit.copy"]).toEqual({ common: ["Ctrl+C"] }); + expect(useDAWStore.getState().deleteCustomKeyboardProfile(firstId)).toBe(true); + expect(useDAWStore.getState().activeCustomKeyboardProfileId).toBeNull(); + expect(useDAWStore.getState().customShortcuts).toEqual({}); + }); + + it("switches from a custom overlay to a built-in keyboard profile as one transaction", () => { + const profileId = useDAWStore.getState().createCustomKeyboardProfile("Editing", "cubase"); + expect(profileId).toBeTruthy(); + useDAWStore.getState().addCustomShortcutBinding("edit.copy", "F8"); + + useDAWStore.getState().setKeyboardShortcutProfile("pro_tools"); + + expect(useDAWStore.getState()).toMatchObject({ + keyboardShortcutProfileId: "pro_tools", + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + }); + expect(JSON.parse(storage.getItem(INPUT_PROFILE_SETTINGS_KEY) ?? "{}")).toMatchObject({ + schemaVersion: 1, + keyboardProfileId: "pro_tools", + }); + expect(JSON.parse(storage.getItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY) ?? "{}")).toMatchObject({ + schemaVersion: 2, + activeProfileId: null, + }); + }); + + it("exports and imports a validated profile with a fresh ID and preserves independent mouse choice", () => { + const originalId = useDAWStore.getState().createCustomKeyboardProfile("Portable", "cubase"); + useDAWStore.getState().setCustomShortcutBindings("edit.copy", ["Ctrl+C", "F6"], "macos"); + const serialized = useDAWStore.getState().exportActiveCustomKeyboardProfile(); + expect(serialized).toContain("openstudio-keyboard-profile"); + + const result = useDAWStore.getState().importCustomKeyboardProfile( + serialized ?? "", + ["edit.copy"], + ); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.profile.id).not.toBe(originalId); + expect(useDAWStore.getState()).toMatchObject({ + activeCustomKeyboardProfileId: result.profile.id, + keyboardShortcutProfileId: "cubase", + mouseBehaviorProfileId: "logic_pro", + }); + }); + + it("keeps explicit unbind compatibility for the legacy setter", () => { + useDAWStore.getState().setCustomShortcut("transport.loop", ""); + expect(useDAWStore.getState().customShortcuts["transport.loop"]) + .toEqual({ common: [] }); + }); + + it("keeps deduplicated profile names unique at the 64-character boundary", () => { + const longName = "A".repeat(64); + const firstId = useDAWStore.getState().createCustomKeyboardProfile(longName); + const secondId = useDAWStore.getState().createCustomKeyboardProfile(longName); + expect(firstId).toBeTruthy(); + expect(secondId).toBeTruthy(); + const names = useDAWStore.getState().customKeyboardProfiles.map((profile) => profile.name); + expect(names).toEqual([longName, `${"A".repeat(62)} 2`]); + expect(new Set(names).size).toBe(2); + expect(names.every((name) => name.length <= 64)).toBe(true); + }); + + it("keeps profile growth within the persisted collection limit", () => { + const profiles: CustomKeyboardShortcutProfile[] = Array.from( + { length: MAX_CUSTOM_KEYBOARD_PROFILES }, + (_, index) => ({ + id: `custom-cap-${index}`, + name: `Profile ${index}`, + baseProfileId: "reaper", + bindings: {}, + createdAt: index, + updatedAt: index, + }), + ); + useDAWStore.setState({ + customKeyboardProfiles: profiles, + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + }); + storage.setItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY, JSON.stringify({ + schemaVersion: 2, + activeProfileId: null, + profiles, + })); + const rawBeforeRejectedGrowth = storage.getItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY); + + expect(useDAWStore.getState().createCustomKeyboardProfile("Too many")).toBeNull(); + expect(useDAWStore.getState().duplicateKeyboardProfile()).toBeNull(); + useDAWStore.getState().addCustomShortcutBinding("edit.copy", "F1"); + expect(useDAWStore.getState().customKeyboardProfiles).toHaveLength(MAX_CUSTOM_KEYBOARD_PROFILES); + expect(useDAWStore.getState().customShortcuts).toEqual({}); + + const imported = useDAWStore.getState().importCustomKeyboardProfile(JSON.stringify({ + schemaVersion: 2, + type: "openstudio-keyboard-profile", + profile: { + ...profiles[0], + id: "custom-import-source", + name: "Import source", + }, + })); + expect(imported).toMatchObject({ success: false }); + expect(useDAWStore.getState()).toMatchObject({ + keyboardShortcutProfileId: "reaper", + mouseBehaviorProfileId: "logic_pro", + activeCustomKeyboardProfileId: null, + }); + expect(storage.getItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY)).toBe(rawBeforeRejectedGrowth); + + expect(useDAWStore.getState().deleteCustomKeyboardProfile(profiles[0].id)).toBe(true); + expect(useDAWStore.getState().createCustomKeyboardProfile("Allowed again")).toBeTruthy(); + expect(useDAWStore.getState().customKeyboardProfiles).toHaveLength(MAX_CUSTOM_KEYBOARD_PROFILES); + }); + + it("rejects binding and action growth that could not be loaded next launch", () => { + const profileId = useDAWStore.getState().createCustomKeyboardProfile("Bounded"); + expect(profileId).toBeTruthy(); + const bindingsAtLimit = Array.from( + { length: MAX_CUSTOM_SHORTCUT_BINDINGS_PER_TARGET }, + (_, index) => `F${index + 1}`, + ); + useDAWStore.getState().setCustomShortcutBindings("edit.copy", bindingsAtLimit); + useDAWStore.getState().addCustomShortcutBinding("edit.copy", "F13"); + expect(useDAWStore.getState().customShortcuts["edit.copy"]) + .toEqual({ common: bindingsAtLimit }); + useDAWStore.getState().addCustomShortcutBinding("edit.copy", "F1"); + expect(useDAWStore.getState().customShortcuts["edit.copy"]) + .toEqual({ common: bindingsAtLimit }); + useDAWStore.getState().setCustomShortcutBindings("edit.copy", ["F13"], "windows"); + expect(useDAWStore.getState().customShortcuts["edit.copy"]) + .toEqual({ common: bindingsAtLimit, windows: ["F13"] }); + useDAWStore.getState().setCustomShortcutBindings( + "edit.copy", + [...bindingsAtLimit, "F13"], + ); + expect(useDAWStore.getState().customShortcuts["edit.copy"]) + .toEqual({ common: bindingsAtLimit, windows: ["F13"] }); + + const actionsAtLimit = Object.fromEntries(Array.from( + { length: MAX_CUSTOM_SHORTCUT_ACTIONS_PER_PROFILE }, + (_, index) => [`custom.action.${index}`, { common: ["F1"] }], + )) as CustomShortcutMap; + const active = useDAWStore.getState().customKeyboardProfiles.find( + (profile) => profile.id === profileId, + ); + expect(active).toBeTruthy(); + useDAWStore.setState({ + customShortcuts: actionsAtLimit, + customKeyboardProfiles: useDAWStore.getState().customKeyboardProfiles.map((profile) => ( + profile.id === profileId ? { ...profile, bindings: actionsAtLimit } : profile + )), + }); + useDAWStore.getState().setCustomShortcut("custom.action.0", "F2"); + useDAWStore.getState().setCustomShortcut("custom.action.overflow", "F2"); + expect(Object.keys(useDAWStore.getState().customShortcuts)) + .toHaveLength(MAX_CUSTOM_SHORTCUT_ACTIONS_PER_PROFILE); + + const persisted = JSON.parse(storage.getItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY) ?? "null"); + expect(parsePersistedCustomKeyboardProfiles(persisted)).not.toBeNull(); + }); + + it("keeps profile state atomic when local storage rejects a write", () => { + const profileId = useDAWStore.getState().createCustomKeyboardProfile("Durable"); + expect(profileId).toBeTruthy(); + useDAWStore.getState().setCustomShortcutBindings("edit.copy", ["Ctrl+C"]); + const serialized = useDAWStore.getState().exportActiveCustomKeyboardProfile(); + expect(serialized).toBeTruthy(); + + const before = useDAWStore.getState(); + const profilesBefore = before.customKeyboardProfiles; + const shortcutsBefore = before.customShortcuts; + const activeBefore = before.activeCustomKeyboardProfileId; + const keyboardBefore = before.keyboardShortcutProfileId; + const persistedInputBefore = storage.getItem(INPUT_PROFILE_SETTINGS_KEY); + const persistedBefore = storage.getItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY); + vi.spyOn(storage, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + + useDAWStore.getState().addCustomShortcutBinding("edit.paste", "F9"); + expect(useDAWStore.getState().createCustomKeyboardProfile("Rejected")).toBeNull(); + expect(useDAWStore.getState().duplicateKeyboardProfile("Rejected copy")).toBeNull(); + expect(useDAWStore.getState().renameCustomKeyboardProfile(profileId!, "Rejected rename")).toBe(false); + expect(useDAWStore.getState().deleteCustomKeyboardProfile(profileId!)).toBe(false); + expect(useDAWStore.getState().activateCustomKeyboardProfile(null)).toBe(false); + const imported = useDAWStore.getState().importCustomKeyboardProfile(serialized ?? ""); + + expect(imported).toMatchObject({ success: false }); + expect(useDAWStore.getState()).toMatchObject({ + customKeyboardProfiles: profilesBefore, + customShortcuts: shortcutsBefore, + activeCustomKeyboardProfileId: activeBefore, + keyboardShortcutProfileId: keyboardBefore, + }); + expect(useDAWStore.getState().customKeyboardProfiles).toBe(profilesBefore); + expect(useDAWStore.getState().customShortcuts).toBe(shortcutsBefore); + expect(storage.getItem(INPUT_PROFILE_SETTINGS_KEY)).toBe(persistedInputBefore); + expect(storage.getItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY)).toBe(persistedBefore); + }); + + it("rolls back both persistence records when the second profile write exceeds quota", () => { + const firstId = useDAWStore.getState().createCustomKeyboardProfile("First", "cubase"); + const secondId = useDAWStore.getState().createCustomKeyboardProfile("Second", "reaper"); + expect(firstId).toBeTruthy(); + expect(secondId).toBeTruthy(); + if (!firstId || !secondId) return; + expect(useDAWStore.getState().activateCustomKeyboardProfile(firstId)).toBe(true); + const serialized = useDAWStore.getState().exportActiveCustomKeyboardProfile(); + expect(serialized).toBeTruthy(); + + const attempts: Array<{ + name: string; + invoke: () => unknown; + assertRejected: (result: unknown) => void; + }> = [ + { + name: "create", + invoke: () => useDAWStore.getState().createCustomKeyboardProfile("Rejected create"), + assertRejected: (result) => expect(result).toBeNull(), + }, + { + name: "duplicate", + invoke: () => useDAWStore.getState().duplicateKeyboardProfile("Rejected duplicate"), + assertRejected: (result) => expect(result).toBeNull(), + }, + { + name: "activate", + invoke: () => useDAWStore.getState().activateCustomKeyboardProfile(secondId), + assertRejected: (result) => expect(result).toBe(false), + }, + { + name: "deactivate", + invoke: () => useDAWStore.getState().activateCustomKeyboardProfile(null), + assertRejected: (result) => expect(result).toBe(false), + }, + { + name: "select built-in", + invoke: () => useDAWStore.getState().setKeyboardShortcutProfile("pro_tools"), + assertRejected: (result) => expect(result).toBeUndefined(), + }, + { + name: "delete active", + invoke: () => useDAWStore.getState().deleteCustomKeyboardProfile(firstId), + assertRejected: (result) => expect(result).toBe(false), + }, + { + name: "import", + invoke: () => useDAWStore.getState().importCustomKeyboardProfile(serialized ?? ""), + assertRejected: (result) => expect(result).toMatchObject({ success: false }), + }, + ]; + + for (const attempt of attempts) { + const before = useDAWStore.getState(); + const profilesBefore = before.customKeyboardProfiles; + const shortcutsBefore = before.customShortcuts; + const activeBefore = before.activeCustomKeyboardProfileId; + const keyboardBefore = before.keyboardShortcutProfileId; + const persistedInputBefore = storage.getItem(INPUT_PROFILE_SETTINGS_KEY); + const persistedProfilesBefore = storage.getItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY); + const realSetItem = storage.setItem.bind(storage); + const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => { + if (key === CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY) { + throw new DOMException(`${attempt.name} quota exceeded`, "QuotaExceededError"); + } + realSetItem(key, value); + }); + + const result = attempt.invoke(); + attempt.assertRejected(result); + setItemSpy.mockRestore(); + + expect(useDAWStore.getState().customKeyboardProfiles).toBe(profilesBefore); + expect(useDAWStore.getState().customShortcuts).toBe(shortcutsBefore); + expect(useDAWStore.getState()).toMatchObject({ + activeCustomKeyboardProfileId: activeBefore, + keyboardShortcutProfileId: keyboardBefore, + }); + expect(storage.getItem(INPUT_PROFILE_SETTINGS_KEY)).toBe(persistedInputBefore); + expect(storage.getItem(CUSTOM_KEYBOARD_PROFILE_STORAGE_KEY)).toBe(persistedProfilesBefore); + } + }); +}); diff --git a/frontend/src/__tests__/customShortcutProfiles.test.ts b/frontend/src/__tests__/customShortcutProfiles.test.ts new file mode 100644 index 0000000..307a50b --- /dev/null +++ b/frontend/src/__tests__/customShortcutProfiles.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CUSTOM_KEYBOARD_PROFILE_SCHEMA_VERSION, + MAX_CUSTOM_KEYBOARD_PROFILES, + MAX_CUSTOM_SHORTCUT_BINDINGS_PER_TARGET, + exportCustomKeyboardProfile, + getCustomShortcutTargetBindings, + hasCustomShortcutOverride, + migrateLegacyCustomShortcuts, + parseImportedCustomKeyboardProfile, + parsePersistedCustomKeyboardProfiles, + removeCustomShortcutTarget, + resolveCustomShortcutBindings, + setCustomShortcutTargetBindings, + type CustomKeyboardShortcutProfile, + type CustomShortcutMap, +} from "../utils/customShortcutProfiles"; + +describe("custom keyboard shortcut profiles", () => { + it("migrates legacy single-string bindings and preserves explicit unbinds", () => { + const migrated = migrateLegacyCustomShortcuts({ + "transport.record": "Ctrl+R", + "transport.loop": "", + "invalid action id!": "Ctrl+X", + }, "reaper", 123); + + expect(migrated).toMatchObject({ + schemaVersion: 2, + activeProfileId: "custom-migrated-shortcuts", + profiles: [{ + name: "My Shortcuts", + baseProfileId: "reaper", + createdAt: 123, + bindings: { + "transport.record": { common: ["Ctrl+R"] }, + "transport.loop": { common: [] }, + }, + }], + }); + }); + + it("resolves platform lists before common and distinguishes missing from empty", () => { + const bindings: CustomShortcutMap = { + "transport.record": { + common: ["Ctrl+R", "F12"], + macos: ["Command+Code:KeyR"], + windows: [], + }, + "transport.loop": { macos: [] }, + }; + + expect(resolveCustomShortcutBindings(bindings, "transport.record", "macos")) + .toEqual(["Command+Code:KeyR"]); + expect(resolveCustomShortcutBindings(bindings, "transport.record", "windows")).toEqual([]); + expect(resolveCustomShortcutBindings(bindings, "transport.record", "linux")) + .toEqual(["Ctrl+R", "F12"]); + expect(resolveCustomShortcutBindings(bindings, "transport.record", "other")) + .toEqual(["Ctrl+R", "F12"]); + expect(resolveCustomShortcutBindings(bindings, "transport.loop", "windows")).toBeUndefined(); + expect(resolveCustomShortcutBindings(bindings, "transport.loop", "macos")).toEqual([]); + expect(hasCustomShortcutOverride(bindings, "transport.loop", "windows")).toBe(false); + expect(hasCustomShortcutOverride(bindings, "transport.loop", "macos")).toBe(true); + }); + + it("adds, normalizes, removes, and explicitly empties one target without disturbing others", () => { + let bindings = setCustomShortcutTargetBindings({}, "edit.copy", "common", ["ctrl+c", "Ctrl+C"]); + bindings = setCustomShortcutTargetBindings(bindings, "edit.copy", "macos", ["Command+Code:KeyC"]); + expect(getCustomShortcutTargetBindings(bindings["edit.copy"], "common")).toEqual(["Ctrl+C"]); + expect(getCustomShortcutTargetBindings(bindings["edit.copy"], "macos")) + .toEqual(["Command+Code:KeyC"]); + + bindings = setCustomShortcutTargetBindings(bindings, "edit.copy", "macos", []); + expect(resolveCustomShortcutBindings(bindings, "edit.copy", "macos")).toEqual([]); + bindings = removeCustomShortcutTarget(bindings, "edit.copy", "macos"); + expect(resolveCustomShortcutBindings(bindings, "edit.copy", "macos")).toEqual(["Ctrl+C"]); + bindings = removeCustomShortcutTarget(bindings, "edit.copy"); + expect(bindings).toEqual({}); + }); + + it("round-trips a versioned export and assigns imports a new local identity", () => { + vi.spyOn(Date, "now").mockReturnValue(999); + const profile: CustomKeyboardShortcutProfile = { + id: "custom-source", + name: "Editing Keys", + baseProfileId: "cubase", + bindings: { + "edit.splitAtCursor": { + common: ["Ctrl+E", "Code:KeyS"], + windows: [], + }, + }, + createdAt: 10, + updatedAt: 20, + }; + const parsed = parseImportedCustomKeyboardProfile( + exportCustomKeyboardProfile(profile), + new Set(["edit.splitAtCursor"]), + ); + expect(parsed.success).toBe(true); + if (!parsed.success) return; + expect(parsed.profile.id).not.toBe(profile.id); + expect(parsed.profile).toMatchObject({ + name: "Editing Keys", + baseProfileId: "cubase", + createdAt: 999, + updatedAt: 999, + bindings: profile.bindings, + }); + vi.restoreAllMocks(); + }); + + it("rejects malformed, unsupported, unknown-action, and invalid-key imports", () => { + const envelope = (bindings: unknown) => JSON.stringify({ + schemaVersion: CUSTOM_KEYBOARD_PROFILE_SCHEMA_VERSION, + type: "openstudio-keyboard-profile", + profile: { + id: "custom-import", + name: "Imported", + baseProfileId: "openstudio", + bindings, + createdAt: 1, + updatedAt: 1, + }, + }); + + expect(parseImportedCustomKeyboardProfile("not json")).toMatchObject({ success: false }); + expect(parseImportedCustomKeyboardProfile(JSON.stringify({ schemaVersion: 1 }))) + .toMatchObject({ success: false }); + expect(parseImportedCustomKeyboardProfile( + envelope({ "unknown.action": { common: ["Ctrl+K"] } }), + new Set(["known.action"]), + )).toMatchObject({ success: false }); + expect(parseImportedCustomKeyboardProfile( + envelope({ "known.action": { common: ["Ctrl+"] } }), + new Set(["known.action"]), + )).toMatchObject({ success: false }); + }); + + it("rejects corrupted persisted collections without selecting dangling IDs", () => { + expect(parsePersistedCustomKeyboardProfiles({ + schemaVersion: 2, + activeProfileId: "missing", + profiles: [], + })).toEqual({ + schemaVersion: 2, + activeProfileId: null, + profiles: [], + }); + expect(parsePersistedCustomKeyboardProfiles({ + schemaVersion: 2, + activeProfileId: null, + profiles: [{ id: "bad id with spaces" }], + })).toBeNull(); + }); + + it("accepts persisted collections at their limits and rejects values beyond them", () => { + const profile = (index: number, bindings: CustomShortcutMap = {}): CustomKeyboardShortcutProfile => ({ + id: `custom-limit-${index}`, + name: `Profile ${index}`, + baseProfileId: "openstudio", + bindings, + createdAt: index, + updatedAt: index, + }); + const profiles = Array.from( + { length: MAX_CUSTOM_KEYBOARD_PROFILES }, + (_, index) => profile(index), + ); + expect(parsePersistedCustomKeyboardProfiles({ + schemaVersion: 2, + activeProfileId: profiles[0].id, + profiles, + })?.profiles).toHaveLength(MAX_CUSTOM_KEYBOARD_PROFILES); + expect(parsePersistedCustomKeyboardProfiles({ + schemaVersion: 2, + activeProfileId: null, + profiles: [...profiles, profile(MAX_CUSTOM_KEYBOARD_PROFILES)], + })).toBeNull(); + + const atLimit = Array.from( + { length: MAX_CUSTOM_SHORTCUT_BINDINGS_PER_TARGET }, + (_, index) => `F${index + 1}`, + ); + expect(parsePersistedCustomKeyboardProfiles({ + schemaVersion: 2, + activeProfileId: "custom-bindings", + profiles: [profile(0, { "edit.copy": { common: atLimit } })], + })).not.toBeNull(); + expect(parsePersistedCustomKeyboardProfiles({ + schemaVersion: 2, + activeProfileId: "custom-bindings", + profiles: [profile(0, { "edit.copy": { common: [...atLimit, "F13"] } })], + })).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/detachedWindowAuthority.test.ts b/frontend/src/__tests__/detachedWindowAuthority.test.ts new file mode 100644 index 0000000..ea7dbba --- /dev/null +++ b/frontend/src/__tests__/detachedWindowAuthority.test.ts @@ -0,0 +1,827 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { getRegisteredAction, getRegisteredActions } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + type MIDIEvent, + type MidiEditorSession, + type Track, + useDAWStore, +} from "../store/useDAWStore"; +import { + applyDetachedMidiQuantizeRequest, + applyDetachedLoopRegionRequest, + executeDetachedMainActionRequest, + getDetachedActionOwnership, + isLiveDetachedMidiSessionId, + isDetachedMainActionId, + parseDetachedMainActionRequest, + setDetachedMainActionAvailability, +} from "../utils/detachedMainActionRouting"; +import { noteIdFor } from "../utils/midiNotes"; +import { dispatchGlobalShortcut, getEffectiveActionShortcuts } from "../utils/globalShortcutDispatcher"; +import { activateShortcutContext, resetShortcutContextForTests } from "../utils/shortcutContext"; +import { KEYBOARD_SHORTCUT_PROFILES } from "../utils/shortcutProfiles"; +import { + applyRemoteMixerUISnapshot, + cancelPendingMixerRemoteEdit, + extractMixerUISnapshot, + flushPendingMixerRemoteEdit, +} from "../utils/mixerWindowSync"; +import { + applyMidiEditorUISnapshot, + cancelPendingMidiRemoteEdits, + extractMidiEditorUISnapshot, + flushPendingMidiRemoteEdits, + parseMidiEditorUISnapshot, +} from "../utils/midiEditorWindowSync"; + +const originalState = useDAWStore.getState(); + +function audioClip(id: string): AudioClip { + return { + id, + filePath: `C:/audio/${id}.wav`, + name: id, + startTime: 0, + duration: 2, + offset: 0, + color: "#123456", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }; +} + +function noteEvents(velocity: number): MIDIEvent[] { + return [ + { type: "noteOn", timestamp: 0.25, note: 60, velocity, channel: 1 }, + { type: "noteOff", timestamp: 0.75, note: 60, velocity: 0, channel: 1 }, + ]; +} + +function midiClip(id: string, velocity = 60): MIDIClip { + return { + id, + name: id, + startTime: 10, + duration: 4, + sourceLength: 4, + loopLength: 4, + events: noteEvents(velocity), + ccEvents: [], + color: "#654321", + }; +} + +function windowedMidiSession( + trackId: string, + clipId: string, + sessionId = "midi-window-session", +): MidiEditorSession { + return { + sessionId, + trackId, + clipId, + mode: "windowed", + selectedNoteIds: [], + midiEditRange: null, + editCursorTime: null, + activeTool: "select", + visibleLanes: [], + activeLaneId: "velocity", + scrollY: 0, + windowPixelsPerSecond: 100, + windowScrollX: 0, + openedAt: 1, + updatedAt: 1, + }; +} + +function resetProject(): void { + cancelPendingMixerRemoteEdit(); + cancelPendingMidiRemoteEdits(); + commandManager.clear(); + resetShortcutContextForTests(); + useDAWStore.setState({ + tracks: [], + trackGroups: [], + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + selectedClipId: null, + selectedClipIds: [], + selectedNoteIds: [], + midiEditorSessions: [], + activeMidiEditorSessionId: null, + dockedMidiEditorSessionId: null, + pianoRollTrackId: null, + pianoRollClipId: null, + showPianoRoll: false, + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + timeSelection: null, + canUndo: false, + canRedo: false, + isModified: false, + }); +} + +function currentMidiClip(trackId: string, clipId: string): MIDIClip { + return useDAWStore.getState().tracks + .find((track) => track.id === trackId)! + .midiClips.find((clip) => clip.id === clipId)!; +} + +beforeEach(resetProject); + +afterEach(() => { + vi.restoreAllMocks(); + cancelPendingMixerRemoteEdit(); + cancelPendingMidiRemoteEdits(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("authoritative detached-window project routing", () => { + it("classifies every catalog action and every macOS/Windows profile binding for detached ownership", () => { + const actions = getRegisteredActions(); + for (const profile of KEYBOARD_SHORTCUT_PROFILES) { + useDAWStore.setState({ + keyboardShortcutProfileId: profile.id, + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + }); + for (const action of actions) { + const ownership = getDetachedActionOwnership(action.id); + expect(ownership, `${profile.id}:${action.id}`).not.toBeNull(); + expect( + isDetachedMainActionId(action.id), + `${profile.id}:${action.id}`, + ).toBe(ownership !== "local-editor"); + for (const platform of ["macos", "windows"] as const) { + for (const binding of getEffectiveActionShortcuts(action, platform)) { + expect(ownership, `${profile.id}:${platform}:${binding}:${action.id}`).not.toBeNull(); + } + } + } + } + }); + + it.each([ + ["mixer", { kind: "mixer" } as const], + ["midi", { kind: "piano_roll", sessionId: "detached-midi" } as const], + ["plugin", { kind: "plugin", sessionId: "detached-plugin" } as const], + ])("forwards a project transport binding from the detached %s realm without local mutation", ( + role, + context, + ) => { + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + transport: { ...useDAWStore.getState().transport, currentTime: 5 }, + }); + setDetachedMainActionAvailability(["transport.rewind"]); + activateShortcutContext(context); + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + const preventDefault = vi.fn(); + + expect(dispatchGlobalShortcut({ + key: "Home", + code: "Home", + source: "browser", + preventDefault, + }, "windows", { role })).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ + command: "action.execute", + actionId: "transport.rewind", + })); + expect(useDAWStore.getState().transport.currentTime).toBe(5); + }); + + it("rejects arbitrary action payloads and filters track selection in the main realm", () => { + expect(parseDetachedMainActionRequest({ + command: "action.execute", + actionId: "internal.runAnything", + selectedTrackIds: [], + })).toBeNull(); + expect(parseDetachedMainActionRequest({ + command: "action.execute", + actionId: "track.toggleSelectedMute", + selectedTrackIds: "track-a", + })).toBeNull(); + + const track = createDefaultTrack("track-a", "A", "#111", "audio", []); + useDAWStore.setState({ tracks: [track] }); + const execute = vi.fn(); + expect(executeDetachedMainActionRequest({ + command: "action.execute", + actionId: "track.toggleSelectedMute", + selectedTrackIds: ["missing", "track-a", "track-a"], + }, () => ({ + canHandleShortcut: () => useDAWStore.getState().selectedTrackIds.length > 0, + execute, + }))).toBe(true); + expect(execute).toHaveBeenCalledOnce(); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["track-a"]); + }); + + it("runs a mixer structural shortcut in main against the complete source track", async () => { + const source = createDefaultTrack("source", "Source", "#111", "audio", []); + source.clips = [audioClip("full-audio")]; + useDAWStore.setState({ + tracks: [source], + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + }); + const duplicateSelectedTracks = vi.fn(async () => { + const state = useDAWStore.getState(); + const selected = state.tracks.find((track) => track.id === state.selectedTrackIds[0])!; + expect(selected.clips.map((clip) => clip.id)).toEqual(["full-audio"]); + const duplicate: Track = { + ...structuredClone(selected), + id: "source-copy", + clips: selected.clips.map((clip) => ({ ...clip, id: `${clip.id}-copy` })), + }; + useDAWStore.setState({ tracks: [selected, duplicate] }); + return [duplicate.id]; + }); + useDAWStore.setState({ duplicateSelectedTracks }); + + expect(executeDetachedMainActionRequest({ + command: "action.execute", + actionId: "track.duplicateSelected", + selectedTrackIds: ["source"], + }, getRegisteredAction)).toBe(true); + await Promise.resolve(); + + expect(duplicateSelectedTracks).toHaveBeenCalledOnce(); + expect(useDAWStore.getState().tracks[1].clips[0].id).toBe("full-audio-copy"); + }); + + it("preserves clip content and coalesces a detached mixer fader burst into one undo", () => { + const track = createDefaultTrack("track-a", "A", "#111", "audio", []); + track.clips = [audioClip("audio-a")]; + useDAWStore.setState({ tracks: [track] }); + const first = structuredClone(extractMixerUISnapshot()); + first.tracks[0].volumeDB = -3; + first.tracks[0].volume = 0.7; + applyRemoteMixerUISnapshot(first); + const second = structuredClone(first); + second.tracks[0].volumeDB = -9; + second.tracks[0].volume = 0.35; + applyRemoteMixerUISnapshot(second); + + expect(flushPendingMixerRemoteEdit()).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks[0]).toMatchObject({ volumeDB: -9, volume: 0.35 }); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual(["audio-a"]); + + // A main-window clip mutation after the mixer packet is outside the + // detached mixer's ownership and must survive mixer undo/redo. + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((candidate) => candidate.id === track.id + ? { ...candidate, clips: candidate.clips.map((clip) => ({ ...clip, name: "main-edit" })) } + : candidate), + })); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0]).toMatchObject({ volumeDB: 0, volume: 0.8 }); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ id: "audio-a", name: "main-edit" }); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0]).toMatchObject({ volumeDB: -9, volume: 0.35 }); + expect(useDAWStore.getState().tracks[0].clips[0].name).toBe("main-edit"); + }); + + it("splits rapid detached mixer edits when the target or parameter changes", () => { + const firstTrack = createDefaultTrack("track-a", "A", "#111", "audio", []); + const secondTrack = createDefaultTrack("track-b", "B", "#222", "audio", []); + useDAWStore.setState({ tracks: [firstTrack, secondTrack] }); + const panPacket = structuredClone(extractMixerUISnapshot()); + panPacket.tracks[0].pan = 0.4; + applyRemoteMixerUISnapshot(panPacket); + const volumePacket = structuredClone(panPacket); + volumePacket.tracks[1].volumeDB = -6; + volumePacket.tracks[1].volume = 0.5; + applyRemoteMixerUISnapshot(volumePacket); + + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(flushPendingMixerRemoteEdit()).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(2); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].pan).toBe(0.4); + expect(useDAWStore.getState().tracks[1]).toMatchObject({ volumeDB: 0, volume: 0.8 }); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].pan).toBe(0); + }); + + it("does not create mixer history when a detached gesture returns to its start", () => { + const track = createDefaultTrack("track-a", "A", "#111", "audio", []); + useDAWStore.setState({ tracks: [track] }); + const changed = structuredClone(extractMixerUISnapshot()); + changed.tracks[0].pan = 0.5; + applyRemoteMixerUISnapshot(changed); + const restored = structuredClone(changed); + restored.tracks[0].pan = 0; + applyRemoteMixerUISnapshot(restored); + + expect(flushPendingMixerRemoteEdit()).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks[0].pan).toBe(0); + }); + + it("uses boundary-only mixer packets to commit two quick gestures on the same target", () => { + const track = createDefaultTrack("track-a", "A", "#111", "audio", []); + useDAWStore.setState({ tracks: [track] }); + + const firstPreview = structuredClone(extractMixerUISnapshot()); + firstPreview.editBoundaryToken = "remote-mixer:0"; + firstPreview.tracks[0].pan = 0.25; + applyRemoteMixerUISnapshot(firstPreview); + const firstCommit = structuredClone(firstPreview); + firstCommit.editBoundaryToken = "remote-mixer:1"; + applyRemoteMixerUISnapshot(firstCommit); + + const secondPreview = structuredClone(firstCommit); + secondPreview.tracks[0].pan = 0.75; + applyRemoteMixerUISnapshot(secondPreview); + const secondCommit = structuredClone(secondPreview); + secondCommit.editBoundaryToken = "remote-mixer:2"; + applyRemoteMixerUISnapshot(secondCommit); + + expect(commandManager.getUndoStack()).toHaveLength(2); + expect(flushPendingMixerRemoteEdit()).toBe(false); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].pan).toBe(0.25); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].pan).toBe(0); + }); + + it("coalesces multiple MIDI preview packets and supports authoritative undo/redo", () => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [midiClip("midi-clip", 60)]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ + tracks: [track], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + pianoRollTrackId: track.id, + pianoRollClipId: "midi-clip", + selectedTrackIds: ["unrelated-main-selection"], + selectedClipIds: ["unrelated-main-clip"], + }); + const first = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + first.tracks[0].midiClips[0].events = noteEvents(72); + first.selectedTrackIds = [track.id]; + first.selectedClipIds = ["midi-clip"]; + applyMidiEditorUISnapshot(first); + const second = structuredClone(first); + second.tracks[0].midiClips[0].events = noteEvents(88); + applyMidiEditorUISnapshot(second); + + expect(flushPendingMidiRemoteEdits()).toBe(1); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(currentMidiClip(track.id, "midi-clip").events[0].velocity).toBe(88); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["unrelated-main-selection"]); + expect(useDAWStore.getState().selectedClipIds).toEqual(["unrelated-main-clip"]); + + useDAWStore.getState().undo(); + expect(currentMidiClip(track.id, "midi-clip").events[0].velocity).toBe(60); + useDAWStore.getState().redo(); + expect(currentMidiClip(track.id, "midi-clip").events[0].velocity).toBe(88); + }); + + it("does not create MIDI history when a multi-packet gesture is cancelled", () => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [midiClip("midi-clip", 60)]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ + tracks: [track], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + pianoRollTrackId: track.id, + pianoRollClipId: "midi-clip", + }); + const changed = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + changed.tracks[0].midiClips[0].events = noteEvents(91); + applyMidiEditorUISnapshot(changed); + const restored = structuredClone(changed); + restored.tracks[0].midiClips[0].events = noteEvents(60); + applyMidiEditorUISnapshot(restored); + + expect(flushPendingMidiRemoteEdits()).toBe(0); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(currentMidiClip(track.id, "midi-clip").events[0].velocity).toBe(60); + }); + + it("splits rapid MIDI edits when their semantic property target changes", () => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [midiClip("midi-clip", 60)]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ + tracks: [track], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + pianoRollTrackId: track.id, + pianoRollClipId: "midi-clip", + }); + const velocityPacket = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + velocityPacket.tracks[0].midiClips[0].events = noteEvents(75); + applyMidiEditorUISnapshot(velocityPacket); + const timingPacket = structuredClone(velocityPacket); + timingPacket.tracks[0].midiClips[0].events = timingPacket.tracks[0].midiClips[0].events.map( + (event) => ({ ...event, timestamp: event.timestamp + 0.1 }), + ); + applyMidiEditorUISnapshot(timingPacket); + + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(flushPendingMidiRemoteEdits()).toBe(1); + expect(commandManager.getUndoStack()).toHaveLength(2); + + useDAWStore.getState().undo(); + expect(currentMidiClip(track.id, "midi-clip").events[0]).toMatchObject({ + timestamp: 0.25, + velocity: 75, + }); + useDAWStore.getState().undo(); + expect(currentMidiClip(track.id, "midi-clip").events[0]).toMatchObject({ + timestamp: 0.25, + velocity: 60, + }); + }); + + it("uses boundary-only MIDI packets to commit two quick edits on the same property", () => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [midiClip("midi-clip", 60)]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ + tracks: [track], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + pianoRollTrackId: track.id, + pianoRollClipId: "midi-clip", + }); + + const firstPreview = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + firstPreview.editBoundaryToken = "remote-midi:0"; + firstPreview.tracks[0].midiClips[0].events = noteEvents(70); + applyMidiEditorUISnapshot(firstPreview); + const firstCommit = structuredClone(firstPreview); + firstCommit.editBoundaryToken = "remote-midi:1"; + applyMidiEditorUISnapshot(firstCommit); + + const secondPreview = structuredClone(firstCommit); + secondPreview.tracks[0].midiClips[0].events = noteEvents(90); + applyMidiEditorUISnapshot(secondPreview); + const secondCommit = structuredClone(secondPreview); + secondCommit.editBoundaryToken = "remote-midi:2"; + applyMidiEditorUISnapshot(secondCommit); + + expect(commandManager.getUndoStack()).toHaveLength(2); + expect(flushPendingMidiRemoteEdits()).toBe(0); + useDAWStore.getState().undo(); + expect(currentMidiClip(track.id, "midi-clip").events[0].velocity).toBe(70); + useDAWStore.getState().undo(); + expect(currentMidiClip(track.id, "midi-clip").events[0].velocity).toBe(60); + }); + + it("accepts only MIDI event content and preserves main-owned clip structure", () => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [{ + ...midiClip("midi-clip", 60), + name: "Authoritative name", + startTime: 10, + duration: 4, + sourceLength: 4, + color: "#112233", + groupId: "main-group", + muted: false, + locked: false, + }]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ + tracks: [track], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + }); + + const packet = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + Object.assign(packet.tracks[0].midiClips[0], { + name: "Stale detached name", + startTime: 99, + duration: 99, + sourceLength: 99, + color: "#ffffff", + groupId: "detached-group", + muted: true, + locked: true, + events: noteEvents(96), + ccEvents: [{ cc: 1, time: 0.5, value: 100 }], + quantizeBackup: { events: noteEvents(60), ccEvents: [] }, + }); + + expect(applyMidiEditorUISnapshot(packet)).toBe(true); + expect(currentMidiClip(track.id, "midi-clip")).toMatchObject({ + id: "midi-clip", + name: "Authoritative name", + startTime: 10, + duration: 4, + sourceLength: 4, + color: "#112233", + groupId: "main-group", + muted: false, + locked: false, + events: noteEvents(96), + ccEvents: [{ cc: 1, time: 0.5, value: 100 }], + }); + expect(currentMidiClip(track.id, "midi-clip").quantizeBackup?.events).toEqual(noteEvents(60)); + }); + + it("preserves a concurrent main structural edit on the same MIDI clip through detached undo/redo", () => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [midiClip("midi-clip", 60)]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ tracks: [track], midiEditorSessions: [session] }); + const packet = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + packet.tracks[0].midiClips[0].events = noteEvents(91); + expect(applyMidiEditorUISnapshot(packet)).toBe(true); + expect(flushPendingMidiRemoteEdits()).toBe(1); + + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((candidate) => candidate.id === track.id + ? { + ...candidate, + midiClips: candidate.midiClips.map((clip) => clip.id === "midi-clip" + ? { ...clip, name: "Main rename", startTime: 24, duration: 8, locked: true } + : clip), + } + : candidate), + })); + useDAWStore.getState().undo(); + expect(currentMidiClip(track.id, "midi-clip")).toMatchObject({ + name: "Main rename", + startTime: 24, + duration: 8, + locked: true, + events: noteEvents(60), + }); + useDAWStore.getState().redo(); + expect(currentMidiClip(track.id, "midi-clip")).toMatchObject({ + name: "Main rename", + startTime: 24, + duration: 8, + locked: true, + events: noteEvents(91), + }); + }); + + it.each([ + ["global lock", { globalLocked: true }], + ["item lock", { lockSettings: { items: true, envelopes: false, timeSelection: false, markers: false } }], + ["frozen track", { frozen: true }], + ["clip lock", { clipLocked: true }], + ])("rejects detached MIDI content under %s without history", (_label, condition) => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.frozen = "frozen" in condition ? Boolean(condition.frozen) : false; + track.midiClips = [{ + ...midiClip("midi-clip", 60), + locked: "clipLocked" in condition ? Boolean(condition.clipLocked) : false, + }]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ + tracks: [track], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + ...("globalLocked" in condition ? { globalLocked: condition.globalLocked } : {}), + ...("lockSettings" in condition ? { lockSettings: condition.lockSettings } : {}), + }); + const packet = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + packet.tracks[0].midiClips[0].events = noteEvents(99); + + expect(applyMidiEditorUISnapshot(packet)).toBe(false); + expect(currentMidiClip(track.id, "midi-clip").events).toEqual(noteEvents(60)); + expect(flushPendingMidiRemoteEdits()).toBe(0); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("cancels and restores an uncommitted MIDI preview when a lock engages mid-gesture", () => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [{ ...midiClip("midi-clip", 60), locked: false }]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ + tracks: [track], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + }); + const preview = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + preview.editBoundaryToken = "remote-midi:preview"; + preview.tracks[0].midiClips[0].events = noteEvents(78); + expect(applyMidiEditorUISnapshot(preview)).toBe(true); + expect(currentMidiClip(track.id, "midi-clip").events).toEqual(noteEvents(78)); + + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((candidate) => candidate.id === track.id + ? { + ...candidate, + midiClips: candidate.midiClips.map((clip) => clip.id === "midi-clip" + ? { ...clip, locked: true } + : clip), + } + : candidate), + })); + const latePacket = structuredClone(preview); + latePacket.tracks[0].midiClips[0].events = noteEvents(101); + expect(applyMidiEditorUISnapshot(latePacket)).toBe(false); + + expect(currentMidiClip(track.id, "midi-clip")).toMatchObject({ + locked: true, + events: noteEvents(60), + }); + expect(flushPendingMidiRemoteEdits()).toBe(0); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("revalidates MIDI editability at idle commit even without another detached packet", () => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [midiClip("midi-clip", 60)]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ + tracks: [track], + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + }); + const preview = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + preview.tracks[0].midiClips[0].events = noteEvents(84); + expect(applyMidiEditorUISnapshot(preview)).toBe(true); + expect(currentMidiClip(track.id, "midi-clip").events).toEqual(noteEvents(84)); + + useDAWStore.setState({ globalLocked: true }); + expect(flushPendingMidiRemoteEdits()).toBe(0); + expect(currentMidiClip(track.id, "midi-clip").events).toEqual(noteEvents(60)); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("rejects stale or mismatched detached MIDI sessions", () => { + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [midiClip("midi-clip", 60)]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ tracks: [track], midiEditorSessions: [session] }); + const base = structuredClone(extractMidiEditorUISnapshot(useDAWStore.getState(), session.sessionId)!); + base.tracks[0].midiClips[0].events = noteEvents(90); + + expect(applyMidiEditorUISnapshot({ ...base, sessionId: "stale-session" })).toBe(false); + expect(applyMidiEditorUISnapshot({ ...base, mode: "docked" })).toBe(false); + expect(applyMidiEditorUISnapshot({ + ...base, + trackId: "wrong-track", + tracks: [{ ...base.tracks[0], id: "wrong-track" }], + })).toBe(false); + expect(currentMidiClip(track.id, "midi-clip").events).toEqual(noteEvents(60)); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("quantizes only the validated selection owned by the sending MIDI session", () => { + const firstTrack = createDefaultTrack("midi-a", "MIDI A", "#111", "midi", []); + const secondTrack = createDefaultTrack("midi-b", "MIDI B", "#222", "midi", []); + firstTrack.midiClips = [midiClip("clip-a")]; + secondTrack.midiClips = [midiClip("clip-b")]; + const firstSession = windowedMidiSession(firstTrack.id, "clip-a", "session-a"); + const secondSession = windowedMidiSession(secondTrack.id, "clip-b", "session-b"); + const originalMainSelection = [noteIdFor("clip-b", 0.25, 60)]; + const quantize = vi.fn((trackId?: string, clipId?: string) => { + expect(trackId).toBe("midi-a"); + expect(clipId).toBe("clip-a"); + expect(useDAWStore.getState().selectedNoteIds).toEqual([ + noteIdFor("clip-a", 0.25, 60), + ]); + return [noteIdFor("clip-a", 0, 60)]; + }); + useDAWStore.setState({ + tracks: [firstTrack, secondTrack], + midiEditorSessions: [firstSession, secondSession], + activeMidiEditorSessionId: secondSession.sessionId, + pianoRollTrackId: secondTrack.id, + pianoRollClipId: "clip-b", + selectedNoteIds: originalMainSelection, + quantizeSelectedMIDINotesUsingLast: quantize, + }); + + expect(applyDetachedMidiQuantizeRequest({ + command: "midi.quantize", + sessionId: firstSession.sessionId, + selectedNoteIds: [noteIdFor("clip-a", 0.25, 60)], + midiEditRange: null, + }, "main")).toBe(true); + expect(quantize).toHaveBeenCalledOnce(); + expect(useDAWStore.getState().selectedNoteIds).toEqual(originalMainSelection); + expect(useDAWStore.getState().midiEditorSessions.find((entry) => entry.sessionId === "session-a")?.selectedNoteIds) + .toEqual([noteIdFor("clip-a", 0, 60)]); + + expect(applyDetachedMidiQuantizeRequest({ + command: "midi.quantize", + sessionId: secondSession.sessionId, + selectedNoteIds: [noteIdFor("clip-a", 0.25, 60)], + midiEditRange: null, + }, "main")).toBe(false); + expect(applyDetachedMidiQuantizeRequest({ + command: "midi.quantize", + sessionId: "stale-session", + selectedNoteIds: [], + midiEditRange: null, + }, "main")).toBe(false); + expect(isLiveDetachedMidiSessionId("stale-session", "main")).toBe(false); + }); + + it("validates MIDI payloads and applies loop-from-selection only to its live windowed clip", () => { + expect(parseMidiEditorUISnapshot({ sessionId: "bad", tracks: [] })).toBeNull(); + const track = createDefaultTrack("midi-track", "MIDI", "#222", "midi", []); + track.midiClips = [midiClip("midi-clip")]; + const session = windowedMidiSession(track.id, "midi-clip"); + useDAWStore.setState({ tracks: [track], midiEditorSessions: [session] }); + + expect(applyDetachedLoopRegionRequest({ + command: "transport.setLoopRegion", + sessionId: session.sessionId, + start: 10.25, + end: 10.75, + })).toBe(true); + expect(useDAWStore.getState().transport).toMatchObject({ + loopStart: 10.25, + loopEnd: 10.75, + }); + expect(applyDetachedLoopRegionRequest({ + command: "transport.setLoopRegion", + sessionId: "other-session", + start: 10.25, + end: 10.75, + })).toBe(false); + expect(applyDetachedLoopRegionRequest({ + command: "transport.setLoopRegion", + sessionId: session.sessionId, + start: 9, + end: 15, + })).toBe(false); + }); + + it("flushes a live detached gesture before executing main undo", () => { + const track = createDefaultTrack("track-a", "A", "#111", "audio", []); + useDAWStore.setState({ tracks: [track] }); + const changed = structuredClone(extractMixerUISnapshot()); + changed.tracks[0].pan = -0.75; + applyRemoteMixerUISnapshot(changed); + expect(commandManager.getUndoStack()).toHaveLength(0); + + expect(executeDetachedMainActionRequest({ + command: "action.execute", + actionId: "edit.undo", + selectedTrackIds: [], + }, getRegisteredAction, { + flushPendingEdits: () => { flushPendingMixerRemoteEdit(); }, + })).toBe(true); + expect(useDAWStore.getState().tracks[0].pan).toBe(0); + expect(commandManager.getRedoStack()).toHaveLength(1); + }); + + it("routes detached Ctrl+Z while both replica and advertised undo availability are stale", () => { + const track = createDefaultTrack("track-a", "A", "#111", "audio", []); + useDAWStore.setState({ tracks: [track], canUndo: false, canRedo: false }); + const changed = structuredClone(extractMixerUISnapshot()); + changed.editBoundaryToken = "remote-mixer:live-preview"; + changed.tracks[0].pan = -0.6; + applyRemoteMixerUISnapshot(changed); + expect(commandManager.getUndoStack()).toHaveLength(0); + setDetachedMainActionAvailability([]); + activateShortcutContext({ kind: "mixer" }); + + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + const preventDefault = vi.fn(); + expect(dispatchGlobalShortcut({ + key: "z", + code: "KeyZ", + ctrlKey: true, + source: "browser", + preventDefault, + }, "windows", { + role: "mixer", + canHandleAction: () => false, + })).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(publish).toHaveBeenCalledOnce(); + const request = publish.mock.calls[0][0]; + expect(request).toMatchObject({ command: "action.execute", actionId: "edit.undo" }); + + expect(executeDetachedMainActionRequest(request, getRegisteredAction, { + role: "main", + flushPendingEdits: () => { flushPendingMixerRemoteEdit(); }, + })).toBe(true); + expect(useDAWStore.getState().tracks[0].pan).toBe(0); + expect(commandManager.getRedoStack()).toHaveLength(1); + }); +}); diff --git a/frontend/src/__tests__/editorParityActions.test.ts b/frontend/src/__tests__/editorParityActions.test.ts new file mode 100644 index 0000000..92684ed --- /dev/null +++ b/frontend/src/__tests__/editorParityActions.test.ts @@ -0,0 +1,437 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import pianoRollSource from "../components/PianoRoll.tsx?raw"; +import { nativeBridge } from "../services/NativeBridge"; +import { + getRegisteredAction, + registerScopedActionExecutor, +} from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + getMinimumVisibleTrackHeight, + type AudioClip, + type MIDIClip, + type MIDIEvent, + type Track, + useDAWStore, +} from "../store/useDAWStore"; +import { noteIdFor, parseMIDINotePairs } from "../utils/midiNotes"; +import { + activateShortcutContext, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; + +const originalState = useDAWStore.getState(); +const scopedCleanups: Array<() => void> = []; + +function audioClip(id: string, overrides: Partial = {}): AudioClip { + return { + id, + filePath: `C:/audio/${id}.wav`, + name: id, + startTime: 0, + duration: 1, + offset: 0, + color: "#123456", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + ...overrides, + }; +} + +function midiClip(id: string, events: MIDIEvent[] = [], overrides: Partial = {}): MIDIClip { + return { + id, + name: id, + startTime: 0, + duration: 2, + sourceLength: 2, + loopLength: 2, + events, + ccEvents: [], + color: "#654321", + ...overrides, + }; +} + +function track(id: string, type: Track["type"] = "audio", overrides: Partial = {}): Track { + return { + ...createDefaultTrack(id, id, "#222222", type, []), + ...overrides, + }; +} + +function noteEvents(note: number, start: number, end: number, channel = 1): MIDIEvent[] { + return [ + { type: "noteOn", timestamp: start, note, velocity: 90, channel }, + { type: "noteOff", timestamp: end, note, velocity: 7, releaseVelocity: 7, channel }, + ]; +} + +beforeEach(() => { + commandManager.clear(); + resetShortcutContextForTests(); + activateShortcutContext({ kind: "application" }); + useDAWStore.setState({ + tracks: [], + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + selectedClipId: null, + selectedClipIds: [], + selectedNoteIds: [], + midiEditRange: null, + midiEditorSessions: [], + activeMidiEditorSessionId: null, + dockedMidiEditorSessionId: null, + showPianoRoll: false, + pianoRollTrackId: null, + pianoRollClipId: null, + canUndo: false, + canRedo: false, + isModified: false, + trackHeight: 100, + tcpWidth: 250, + }); +}); + +afterEach(() => { + while (scopedCleanups.length > 0) scopedCleanups.pop()?.(); + vi.restoreAllMocks(); + commandManager.clear(); + resetShortcutContextForTests(); + useDAWStore.setState(originalState); +}); + +describe("vendor-parity editor action catalog", () => { + it("registers exact actions and does not invent unsupported source-only behavior", () => { + const scopedExpectations = new Map([ + ["view.verticalZoomIn", "timeline"], + ["view.verticalZoomOut", "timeline"], + ["edit.selectPreviousClip", "timeline"], + ["edit.selectNextClip", "timeline"], + ["edit.muteSelectedClips", "timeline"], + ["edit.unmuteSelectedClips", "timeline"], + ["track.moveSelectedUp", "track_control_panel"], + ["track.moveSelectedDown", "track_control_panel"], + ["midi.deselectAll", "piano_roll"], + ["midi.selectNextNote", "piano_roll"], + ["midi.selectPreviousNote", "piano_roll"], + ["midi.glueSelectedNotes", "piano_roll"], + ]); + expect(getRegisteredAction("view.togglePianoRoll")?.shortcutScope).toBe("global"); + scopedExpectations.forEach((scope, actionId) => { + expect(getRegisteredAction(actionId)?.shortcutScope, actionId).toBe(scope); + }); + + for (const unsupportedId of [ + "edit.toggleSelectedClipLoop", + "midi.tool.paint", + "midi.tool.playback", + "edit.joinSelectedClipsOrNotes", + "edit.consolidateSelectedClips", + "view.toggleWaveformMultitrackEditor", + "edit.cropSelectedClips", + ]) { + expect(getRegisteredAction(unsupportedId), unsupportedId).toBeUndefined(); + } + }); +}); + +describe("Piano Roll routing and view actions", () => { + it("opens the selected MIDI clip, closes the docked editor, and no-ops without an eligible clip", () => { + useDAWStore.setState({ + tracks: [track("midi-track", "midi", { midiClips: [midiClip("midi-clip")] })], + selectedClipId: "midi-clip", + selectedClipIds: ["midi-clip"], + }); + const action = getRegisteredAction("view.togglePianoRoll")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + expect(useDAWStore.getState()).toMatchObject({ + showPianoRoll: true, + pianoRollTrackId: "midi-track", + pianoRollClipId: "midi-clip", + }); + + action.execute(); + expect(useDAWStore.getState().showPianoRoll).toBe(false); + + useDAWStore.setState({ + tracks: [track("audio-track", "audio", { clips: [audioClip("audio-clip")] })], + selectedClipId: "audio-clip", + selectedClipIds: ["audio-clip"], + }); + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + expect(useDAWStore.getState().showPianoRoll).toBe(false); + }); + + it("closes only the active detached editor even without main-window Piano Roll state", () => { + const firstClose = vi.fn((_actionId: string) => "handled" as const); + const secondClose = vi.fn((_actionId: string) => "handled" as const); + scopedCleanups.push(registerScopedActionExecutor( + { kind: "piano_roll", sessionId: "first" }, + firstClose, + ["view.togglePianoRoll"], + )); + scopedCleanups.push(registerScopedActionExecutor( + { kind: "piano_roll", sessionId: "second" }, + secondClose, + ["view.togglePianoRoll"], + )); + activateShortcutContext({ kind: "piano_roll", sessionId: "second" }); + useDAWStore.setState({ + showPianoRoll: false, + tracks: [], + selectedClipId: null, + selectedClipIds: [], + }); + + const action = getRegisteredAction("view.togglePianoRoll")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + expect(secondClose).toHaveBeenCalledExactlyOnceWith("view.togglePianoRoll"); + expect(firstClose).not.toHaveBeenCalled(); + expect(useDAWStore.getState().showPianoRoll).toBe(false); + }); + + it("routes MIDI selection commands to the exact active editor instance", () => { + const first = vi.fn((_actionId: string) => "handled" as const); + const second = vi.fn((_actionId: string) => "handled" as const); + const ids = ["midi.deselectAll", "midi.selectNextNote", "midi.selectPreviousNote", "midi.glueSelectedNotes"]; + scopedCleanups.push(registerScopedActionExecutor( + { kind: "piano_roll", sessionId: "first" }, + first, + ids, + )); + scopedCleanups.push(registerScopedActionExecutor( + { kind: "piano_roll", sessionId: "second" }, + second, + ids, + )); + activateShortcutContext({ kind: "piano_roll", sessionId: "second" }); + + ids.forEach((actionId) => getRegisteredAction(actionId)?.execute()); + expect(first).not.toHaveBeenCalled(); + expect(second.mock.calls.map(([actionId]) => actionId)).toEqual(ids); + for (const actionId of ids) expect(pianoRollSource).toContain(`actionId === "${actionId}"`); + }); + + it("zooms track height vertically with exact clamping and no undo entry", () => { + const zoomIn = getRegisteredAction("view.verticalZoomIn")!; + const zoomOut = getRegisteredAction("view.verticalZoomOut")!; + zoomIn.execute(); + expect(useDAWStore.getState().trackHeight).toBe(120); + zoomOut.execute(); + expect(useDAWStore.getState().trackHeight).toBe(100); + expect(useDAWStore.getState().canUndo).toBe(false); + + const minimum = getMinimumVisibleTrackHeight([], useDAWStore.getState().tcpWidth); + useDAWStore.setState({ trackHeight: minimum }); + expect(zoomOut.canHandleShortcut?.()).toBe(false); + zoomOut.execute(); + expect(useDAWStore.getState().trackHeight).toBe(minimum); + + useDAWStore.setState({ trackHeight: 500 }); + expect(zoomIn.canHandleShortcut?.()).toBe(false); + zoomIn.execute(); + expect(useDAWStore.getState().trackHeight).toBe(500); + }); +}); + +describe("timeline clip navigation and directional mute", () => { + it("selects adjacent audio/MIDI clips in deterministic time and track order", () => { + useDAWStore.setState({ + tracks: [ + track("first", "midi", { + clips: [audioClip("late", { startTime: 2 })], + midiClips: [midiClip("first-at-one", [], { startTime: 1 })], + }), + track("second", "audio", { clips: [audioClip("second-at-one", { startTime: 1 })] }), + ], + selectedClipId: "first-at-one", + selectedClipIds: ["first-at-one"], + }); + + expect(useDAWStore.getState().selectAdjacentClip("next")).toBe(true); + expect(useDAWStore.getState().selectedClipId).toBe("second-at-one"); + expect(getRegisteredAction("edit.selectNextClip")?.canHandleShortcut?.()).toBe(true); + getRegisteredAction("edit.selectNextClip")?.execute(); + expect(useDAWStore.getState().selectedClipId).toBe("late"); + expect(getRegisteredAction("edit.selectNextClip")?.canHandleShortcut?.()).toBe(false); + expect(useDAWStore.getState().selectAdjacentClip("next")).toBe(false); + + expect(useDAWStore.getState().selectAdjacentClip("previous")).toBe(true); + expect(useDAWStore.getState().selectedClipId).toBe("second-at-one"); + useDAWStore.setState({ selectedClipId: null, selectedClipIds: [] }); + expect(useDAWStore.getState().selectAdjacentClip("previous")).toBe(false); + }); + + it("mutes mixed audio/MIDI selection in one undo step and skips locked clips", () => { + const syncAudio = vi.fn(async () => undefined); + const syncMIDI = vi.fn(async () => undefined); + useDAWStore.setState({ + tracks: [ + track("audio", "audio", { + clips: [ + audioClip("audio-open"), + audioClip("audio-locked", { locked: true }), + ], + }), + track("midi", "midi", { + midiClips: [ + midiClip("midi-open"), + midiClip("midi-already", [], { muted: true }), + ], + }), + ], + selectedClipId: "midi-already", + selectedClipIds: ["audio-open", "audio-locked", "midi-open", "midi-already"], + syncClipsWithBackend: syncAudio, + syncMIDITrackToBackend: syncMIDI, + }); + + expect(useDAWStore.getState().setSelectedClipsMuted(true)).toBe(true); + const muted = useDAWStore.getState().tracks; + expect(muted[0].clips.map((clip) => clip.muted)).toEqual([true, undefined]); + expect(muted[1].midiClips.map((clip) => clip.muted)).toEqual([true, true]); + expect(useDAWStore.getState().canUndo).toBe(true); + expect(syncAudio).toHaveBeenCalledTimes(1); + expect(syncMIDI).toHaveBeenCalledWith("midi", { debounce: false }); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.muted)).toEqual([undefined, undefined]); + expect(useDAWStore.getState().tracks[1].midiClips.map((clip) => clip.muted)).toEqual([undefined, true]); + expect(useDAWStore.getState().canUndo).toBe(false); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips[0].muted).toBe(true); + + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + expect(useDAWStore.getState().setSelectedClipsMuted(true)).toBe(false); + expect(useDAWStore.getState().canUndo).toBe(false); + expect(getRegisteredAction("edit.muteSelectedClips")?.canHandleShortcut?.()).toBe(false); + expect(getRegisteredAction("edit.unmuteSelectedClips")?.canHandleShortcut?.()).toBe(true); + }); +}); + +describe("atomic track reordering", () => { + it("moves a selected folder with its descendants and restores one exact snapshot", () => { + const reorderSpy = vi.spyOn(nativeBridge, "reorderTrack").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [ + track("folder", "audio", { isFolder: true }), + track("child-a", "audio", { parentFolderId: "folder" }), + track("child-b", "midi", { parentFolderId: "folder" }), + track("outside"), + ], + selectedTrackId: "folder", + selectedTrackIds: ["folder"], + }); + + expect(useDAWStore.getState().canMoveSelectedTracks("down")).toBe(true); + expect(useDAWStore.getState().moveSelectedTracks("down")).toBe(true); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.id)) + .toEqual(["outside", "folder", "child-a", "child-b"]); + expect(useDAWStore.getState().tracks.filter((candidate) => candidate.parentFolderId).map((candidate) => candidate.parentFolderId)) + .toEqual(["folder", "folder"]); + expect(useDAWStore.getState().canUndo).toBe(true); + expect(reorderSpy).toHaveBeenCalledTimes(4); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.id)) + .toEqual(["folder", "child-a", "child-b", "outside"]); + expect(useDAWStore.getState().canUndo).toBe(false); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.id)) + .toEqual(["outside", "folder", "child-a", "child-b"]); + }); + + it("moves multi-selection as an ordered block and prevents children crossing folder boundaries", () => { + vi.spyOn(nativeBridge, "reorderTrack").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [track("a"), track("b"), track("c"), track("d")], + selectedTrackId: "c", + selectedTrackIds: ["b", "c"], + }); + expect(useDAWStore.getState().moveSelectedTracks("down")).toBe(true); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.id)).toEqual(["a", "d", "b", "c"]); + + commandManager.clear(); + useDAWStore.setState({ + tracks: [ + track("folder", "audio", { isFolder: true }), + track("child-a", "audio", { parentFolderId: "folder" }), + track("child-b", "audio", { parentFolderId: "folder" }), + track("outside"), + ], + selectedTrackId: "child-b", + selectedTrackIds: ["child-b"], + canUndo: false, + canRedo: false, + }); + expect(useDAWStore.getState().canMoveSelectedTracks("down")).toBe(false); + expect(useDAWStore.getState().moveSelectedTracks("down")).toBe(false); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.id)) + .toEqual(["folder", "child-a", "child-b", "outside"]); + expect(useDAWStore.getState().canUndo).toBe(false); + }); +}); + +describe("Piano Roll glue transaction", () => { + it("glues eligible same-pitch/channel notes once and undoes/redoes exactly", () => { + const events = [ + ...noteEvents(60, 0, 0.4), + ...noteEvents(60, 0.5, 1), + ...noteEvents(64, 0.25, 0.75), + ]; + const syncMIDI = vi.fn(async () => undefined); + useDAWStore.setState({ + tracks: [track("midi", "midi", { midiClips: [midiClip("clip", events)] })], + syncMIDITrackToBackend: syncMIDI, + }); + const selection = [noteIdFor("clip", 0, 60), noteIdFor("clip", 0.5, 60)]; + const nextIds = useDAWStore.getState().glueSelectedMIDINotes("midi", "clip", selection); + expect(nextIds).toEqual([noteIdFor("clip", 0, 60)]); + let pairs = parseMIDINotePairs(useDAWStore.getState().tracks[0].midiClips[0].events, "clip"); + expect(pairs.map((pair) => [pair.noteNumber, pair.startTime, pair.duration])) + .toEqual([[60, 0, 1], [64, 0.25, 0.5]]); + expect(useDAWStore.getState().canUndo).toBe(true); + + useDAWStore.getState().undo(); + pairs = parseMIDINotePairs(useDAWStore.getState().tracks[0].midiClips[0].events, "clip"); + expect(pairs.filter((pair) => pair.noteNumber === 60)).toHaveLength(2); + expect(useDAWStore.getState().canUndo).toBe(false); + useDAWStore.getState().redo(); + pairs = parseMIDINotePairs(useDAWStore.getState().tracks[0].midiClips[0].events, "clip"); + expect(pairs.filter((pair) => pair.noteNumber === 60)).toHaveLength(1); + }); + + it("is undo-safe for no selection, mixed pitches/channels, and locked clips", () => { + const events = [ + ...noteEvents(60, 0, 0.4, 1), + ...noteEvents(60, 0.5, 1, 2), + ...noteEvents(64, 0.25, 0.75, 1), + ]; + useDAWStore.setState({ + tracks: [track("midi", "midi", { midiClips: [midiClip("clip", events)] })], + }); + expect(useDAWStore.getState().glueSelectedMIDINotes("midi", "clip", [])).toEqual([]); + expect(useDAWStore.getState().glueSelectedMIDINotes("midi", "clip", [ + noteIdFor("clip", 0, 60), + noteIdFor("clip", 0.25, 64), + ])).toEqual([]); + expect(useDAWStore.getState().canUndo).toBe(false); + + useDAWStore.setState({ + tracks: [track("midi", "midi", { midiClips: [midiClip("clip", events, { locked: true })] })], + }); + expect(useDAWStore.getState().glueSelectedMIDINotes("midi", "clip", [ + noteIdFor("clip", 0, 60), + noteIdFor("clip", 0.5, 60), + ])).toEqual([]); + expect(useDAWStore.getState().canUndo).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/editorShortcutRegistry.test.ts b/frontend/src/__tests__/editorShortcutRegistry.test.ts new file mode 100644 index 0000000..5755ebf --- /dev/null +++ b/frontend/src/__tests__/editorShortcutRegistry.test.ts @@ -0,0 +1,234 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getRegisteredAction, + getRegisteredActions, + registerScopedActionExecutor, +} from "../store/actionRegistry"; +import { useDAWStore } from "../store/useDAWStore"; +import { + dispatchGlobalShortcut, + matchesActionShortcut, +} from "../utils/globalShortcutDispatcher"; +import { + activateShortcutContext, + registerShortcutSurface, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; +import { getShortcutPlatform } from "../utils/platform"; + +const originalStoreState = { + customShortcuts: useDAWStore.getState().customShortcuts, + deleteRazorEditContent: useDAWStore.getState().deleteRazorEditContent, + deleteSelectedTracks: useDAWStore.getState().deleteSelectedTracks, + deleteWithinTimeSelection: useDAWStore.getState().deleteWithinTimeSelection, + deleteSelectedClips: useDAWStore.getState().deleteSelectedClips, + duplicateSelectedClips: useDAWStore.getState().duplicateSelectedClips, + moveMIDINotes: useDAWStore.getState().moveMIDINotes, + scaleSelectedMIDINoteVelocity: useDAWStore.getState().scaleSelectedMIDINoteVelocity, + setSelectedNoteIds: useDAWStore.getState().setSelectedNoteIds, + pianoRollTrackId: useDAWStore.getState().pianoRollTrackId, + pianoRollClipId: useDAWStore.getState().pianoRollClipId, + selectedClipIds: useDAWStore.getState().selectedClipIds, + selectedTrackIds: useDAWStore.getState().selectedTrackIds, + razorEdits: useDAWStore.getState().razorEdits, + timeSelection: useDAWStore.getState().timeSelection, + toggleMixer: useDAWStore.getState().toggleMixer, +}; + +const cleanups: Array<() => void> = []; + +function hostPrimaryModifier(): { ctrlKey: true } | { metaKey: true } { + return getShortcutPlatform() === "macos" ? { metaKey: true } : { ctrlKey: true }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + while (cleanups.length > 0) cleanups.pop()?.(); + resetShortcutContextForTests(); + useDAWStore.setState(originalStoreState); +}); + +describe("editor shortcut registry", () => { + it("keeps every central action id unique", () => { + const actionIds = getRegisteredActions().map((action) => action.id); + expect(new Set(actionIds).size).toBe(actionIds.length); + }); + + it("centrally defines the formerly hard-coded timeline bindings", () => { + const duplicate = getRegisteredAction("edit.duplicateClips"); + const deleteAction = getRegisteredAction("edit.delete"); + + expect(duplicate).toMatchObject({ shortcut: "Ctrl+D", shortcutScope: "timeline" }); + expect(deleteAction).toMatchObject({ + shortcut: "Delete", + shortcutAliases: ["Backspace"], + shortcutScope: "timeline", + }); + }); + + it("executes duplicate and preserves the timeline delete precedence", () => { + const duplicateSelectedClips = vi.fn(); + const deleteRazorEditContent = vi.fn(); + const deleteSelectedTracks = vi.fn(); + const deleteSelectedClips = vi.fn(); + const deleteWithinTimeSelection = vi.fn(); + useDAWStore.setState({ + duplicateSelectedClips, + deleteRazorEditContent, + deleteSelectedTracks, + deleteSelectedClips, + deleteWithinTimeSelection, + selectedClipIds: ["clip-a", "clip-b"], + selectedTrackIds: ["track-a"], + razorEdits: [{ trackId: "track-a", start: 0, end: 1 }], + timeSelection: { start: 0, end: 1 }, + }); + + getRegisteredAction("edit.duplicateClips")?.execute(); + expect(duplicateSelectedClips).toHaveBeenCalledTimes(1); + + getRegisteredAction("edit.delete")?.execute(); + expect(deleteRazorEditContent).toHaveBeenCalledTimes(1); + expect(deleteSelectedTracks).not.toHaveBeenCalled(); + expect(deleteSelectedClips).not.toHaveBeenCalled(); + expect(deleteWithinTimeSelection).not.toHaveBeenCalled(); + + useDAWStore.setState({ razorEdits: [] }); + getRegisteredAction("edit.delete")?.execute(); + expect(deleteSelectedClips).toHaveBeenCalledTimes(1); + expect(deleteSelectedTracks).not.toHaveBeenCalled(); + + useDAWStore.setState({ selectedClipIds: [] }); + getRegisteredAction("edit.delete")?.execute(); + expect(deleteWithinTimeSelection).toHaveBeenCalledTimes(1); + expect(deleteSelectedTracks).not.toHaveBeenCalled(); + + useDAWStore.setState({ timeSelection: null }); + getRegisteredAction("edit.delete")?.execute(); + expect(deleteSelectedTracks).toHaveBeenCalledTimes(1); + }); + + it("registers Piano Roll tools, edits, movement, and step input in piano scope", () => { + const expected = [ + ["midi.tool.draw", "D"], + ["midi.repeatSelection", "Shift+R"], + ["midi.selectAll", "Ctrl+A"], + ["midi.copySelection", "Ctrl+C"], + ["midi.deleteSelection", "Delete"], + ["midi.movePitchOctaveUp", "Shift+Up"], + ["midi.stepInputC", "C"], + ["midi.stepInputCSharp", "Shift+C"], + ["midi.closeEditor", "Esc"], + ]; + + for (const [actionId, shortcut] of expected) { + expect(getRegisteredAction(actionId)).toMatchObject({ + shortcut, + shortcutScope: "piano_roll", + }); + } + expect(getRegisteredAction("midi.deleteSelection")?.shortcutAliases).toEqual(["Backspace"]); + }); + + it("registers Pitch Editor selection, correction, movement, merge, and tools", () => { + const expected = [ + ["pitch.selectAll", "Ctrl+A"], + ["pitch.correctSelectedToScale", "Q"], + ["pitch.moveUp", "Up"], + ["pitch.moveUpFine", "Shift+Up"], + ["pitch.mergeSelectedNotes", "Ctrl+J"], + ["pitch.tool.select", "1"], + ["pitch.tool.split", "6"], + ["pitch.closeEditor", "Esc"], + ]; + + for (const [actionId, shortcut] of expected) { + expect(getRegisteredAction(actionId)).toMatchObject({ + shortcut, + shortcutScope: "pitch_editor", + }); + } + }); + + it("executes the formerly empty transpose and velocity actions through undo-aware store APIs", () => { + const moveMIDINotes = vi.fn(() => ["moved-note"]); + const scaleSelectedMIDINoteVelocity = vi.fn(); + const setSelectedNoteIds = vi.fn(); + const promptMock = vi.fn() + .mockReturnValueOnce("2.6") + .mockReturnValueOnce("125"); + vi.stubGlobal("prompt", promptMock); + useDAWStore.setState({ + moveMIDINotes, + scaleSelectedMIDINoteVelocity, + setSelectedNoteIds, + pianoRollTrackId: "track-a", + pianoRollClipId: "clip-a", + selectedNoteIds: ["note-a"], + }); + + getRegisteredAction("edit.transpose")?.execute(); + expect(moveMIDINotes).toHaveBeenCalledWith( + "track-a", + "clip-a", + ["note-a"], + 0, + 3, + ); + expect(setSelectedNoteIds).toHaveBeenCalledWith(["moved-note"]); + + getRegisteredAction("edit.velocityScale")?.execute(); + expect(scaleSelectedMIDINoteVelocity).toHaveBeenCalledWith( + "track-a", + "clip-a", + 1.25, + ); + expect(getRegisteredAction("midi.transpose")?.execute).toBeTypeOf("function"); + }); + + it("routes registry execution to only the active editor session", () => { + const first = vi.fn(() => "handled" as const); + const second = vi.fn(() => "handled" as const); + cleanups.push(registerScopedActionExecutor( + { kind: "piano_roll", sessionId: "first" }, + first, + )); + cleanups.push(registerScopedActionExecutor( + { kind: "piano_roll", sessionId: "second" }, + second, + )); + + activateShortcutContext({ kind: "piano_roll", sessionId: "second" }); + getRegisteredAction("midi.tool.draw")?.execute(); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith("midi.tool.draw"); + }); + + it("honors custom scoped bindings and lets the editor win a global conflict", () => { + const toggleMixer = vi.fn(); + const pianoAction = vi.fn(); + useDAWStore.setState({ + toggleMixer, + customShortcuts: { "midi.tool.line": "Ctrl+M" }, + }); + cleanups.push(registerShortcutSurface( + { kind: "piano_roll", sessionId: "custom" }, + (event) => { + if (!matchesActionShortcut(event, "midi.tool.line")) return "unmatched"; + pianoAction(); + return "handled"; + }, + )); + activateShortcutContext({ kind: "piano_roll", sessionId: "custom" }); + + expect(dispatchGlobalShortcut({ + key: "m", + ...hostPrimaryModifier(), + source: "browser", + })).toBe(true); + expect(pianoAction).toHaveBeenCalledTimes(1); + expect(toggleMixer).not.toHaveBeenCalled(); + expect(matchesActionShortcut({ key: "l" }, "midi.tool.line")).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/exhaustiveInputProfileDispatch.test.ts b/frontend/src/__tests__/exhaustiveInputProfileDispatch.test.ts new file mode 100644 index 0000000..35bc981 --- /dev/null +++ b/frontend/src/__tests__/exhaustiveInputProfileDispatch.test.ts @@ -0,0 +1,748 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getActionShortcutScopes, + getRegisteredAction, + getRegisteredActions, + type ActionDef, + type ActionShortcutScope, +} from "../store/actionRegistry"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; +import { + dispatchGlobalShortcut, + getEffectiveActionShortcuts, + resolveRegistryShortcutAction, + type GlobalShortcutPayload, +} from "../utils/globalShortcutDispatcher"; +import { + MOUSE_MODIFIER_CONTEXTS, + resolveMouseModifier, + type MouseModifierCombination, + type PointerModifierEventLike, +} from "../utils/mouseModifierResolver"; +import { getMouseBehaviorProfile } from "../utils/mouseBehaviorProfiles"; +import { + normalizeShortcutBinding, + shortcutMatchesEvent, + type ShortcutPlatform, +} from "../utils/platform"; +import { + KEYBOARD_SHORTCUT_PROFILES, + getProfileActionBindings, +} from "../utils/shortcutProfiles"; +import { + activateShortcutContext, + registerShortcutSurface, + resetShortcutContextForTests, + type EditShortcutContext, +} from "../utils/shortcutContext"; +import { + resolveWheelGesture, + type WheelBehaviorRule, + type WheelEventLike, + type WheelInputDevice, + type WheelPlatform, + type WheelSubtarget, + type WheelSurface, +} from "../utils/wheelGestureResolver"; + +const TEST_PLATFORMS = ["macos", "windows"] as const satisfies readonly ShortcutPlatform[]; + +const WHEEL_SURFACES = [ + "timeline", + "tcp", + "piano_roll", + "pitch_editor", + "browser", + "parameter", +] as const satisfies readonly WheelSurface[]; + +const WHEEL_SUBTARGETS = [ + "content", + "ruler", + "track", + "clip", + "empty", + "grid", + "note", + "sidebar", + "keyboard", + "controller_lane", + "waveform_scale", + "spectrogram_scale", + "automation_lane", + "fade_handle", + "event_volume", + "list", + "tree", + "preview", + "control", + "graph", + "console_fader", +] as const satisfies readonly WheelSubtarget[]; + +const WHEEL_DEVICES = ["mouse", "trackpad", "unknown"] as const satisfies readonly WheelInputDevice[]; + +interface ParsedBinding { + modifiers: readonly string[]; + key: string; +} + +type SyntheticGlobalShortcutPayload = GlobalShortcutPayload & { + location?: number; + getModifierState?: (key: string) => boolean; +}; + +function parseNormalizedBinding(binding: string): ParsedBinding { + const normalized = normalizeShortcutBinding(binding); + if (!normalized) throw new Error(`Invalid shortcut binding: ${binding}`); + const segments = normalized.split("+"); + const modifierNames = new Set([ + "Ctrl", + "Control", + "Command", + "Alt", + "Option", + "Meta", + "AltGraph", + "Shift", + ]); + const modifiers: string[] = []; + let index = 0; + while (index < segments.length && modifierNames.has(segments[index])) { + modifiers.push(segments[index]); + index += 1; + } + const key = segments.slice(index).join("+"); + if (!key) throw new Error(`Binding has no key: ${binding}`); + return { modifiers, key }; +} + +function keyEventIdentity(rawKey: string): Pick { + if (rawKey.startsWith("Code:")) { + const code = rawKey.slice(5); + return keyEventIdentityFromCode(code); + } + const key = rawKey.startsWith("Key:") ? rawKey.slice(4) : rawKey; + if (/^Numpad/.test(key)) return keyEventIdentityFromCode(key); + if (/^[A-Z]$/.test(key)) return { key: key.toLowerCase(), code: `Key${key}` }; + if (/^\d$/.test(key)) return { key, code: `Digit${key}` }; + if (/^F\d{1,2}$/.test(key)) return { key, code: key }; + + const named: Readonly>> = { + Space: { key: " ", code: "Space" }, + Enter: { key: "Enter", code: "Enter" }, + Esc: { key: "Escape", code: "Escape" }, + Tab: { key: "Tab", code: "Tab" }, + Backspace: { key: "Backspace", code: "Backspace" }, + Delete: { key: "Delete", code: "Delete" }, + Insert: { key: "Insert", code: "Insert" }, + Home: { key: "Home", code: "Home" }, + End: { key: "End", code: "End" }, + PageUp: { key: "PageUp", code: "PageUp" }, + PageDown: { key: "PageDown", code: "PageDown" }, + Up: { key: "ArrowUp", code: "ArrowUp" }, + Down: { key: "ArrowDown", code: "ArrowDown" }, + Left: { key: "ArrowLeft", code: "ArrowLeft" }, + Right: { key: "ArrowRight", code: "ArrowRight" }, + Pause: { key: "Pause", code: "Pause" }, + Equal: { key: "=", code: "Equal" }, + Minus: { key: "-", code: "Minus" }, + "=": { key: "=", code: "Equal" }, + "+": { key: "+", code: "Equal" }, + "-": { key: "-", code: "Minus" }, + "[": { key: "[", code: "BracketLeft" }, + "]": { key: "]", code: "BracketRight" }, + ";": { key: ";", code: "Semicolon" }, + "'": { key: "'", code: "Quote" }, + ",": { key: ",", code: "Comma" }, + ".": { key: ".", code: "Period" }, + "/": { key: "/", code: "Slash" }, + "\\": { key: "\\", code: "Backslash" }, + "`": { key: "`", code: "Backquote" }, + }; + const identity = named[key]; + if (!identity) throw new Error(`No synthetic KeyboardEvent mapping for ${rawKey}`); + return identity; +} + +function keyEventIdentityFromCode(code: string): Pick { + if (/^Key[A-Z]$/.test(code)) return { key: code.slice(-1).toLowerCase(), code }; + if (/^Digit\d$/.test(code)) return { key: code.slice(-1), code }; + if (/^Numpad\d$/.test(code)) return { key: code.slice(-1), code, location: 3 }; + const numpadKeys: Readonly> = { + NumpadAdd: "+", + NumpadComma: ",", + NumpadDecimal: ".", + NumpadDivide: "/", + NumpadEnter: "Enter", + NumpadEqual: "=", + NumpadMultiply: "*", + NumpadSubtract: "-", + }; + if (code in numpadKeys) return { key: numpadKeys[code], code, location: 3 }; + const codeKeys: Readonly> = { + Space: "Space", + Enter: "Enter", + Escape: "Esc", + Tab: "Tab", + Backspace: "Backspace", + Delete: "Delete", + Insert: "Insert", + Home: "Home", + End: "End", + PageUp: "PageUp", + PageDown: "PageDown", + ArrowUp: "Up", + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", + Equal: "=", + Minus: "-", + BracketLeft: "[", + BracketRight: "]", + Semicolon: ";", + Quote: "'", + Comma: ",", + Period: ".", + Slash: "/", + Backslash: "\\", + Backquote: "`", + }; + if (code in codeKeys) { + return { ...keyEventIdentity(codeKeys[code]), code }; + } + return keyEventIdentity(code); +} + +function eventForBinding(binding: string, platform: ShortcutPlatform): SyntheticGlobalShortcutPayload { + const parsed = parseNormalizedBinding(binding); + const event: SyntheticGlobalShortcutPayload = { + ...keyEventIdentity(parsed.key), + source: "exhaustive-profile-test", + }; + for (const modifier of parsed.modifiers) { + if (platform === "macos") { + if (modifier === "Ctrl" || modifier === "Command") event.metaKey = true; + else if (modifier === "Alt" || modifier === "Control") event.ctrlKey = true; + else if (modifier === "Option") event.altKey = true; + else if (modifier === "Shift") event.shiftKey = true; + else if (modifier === "AltGraph") { + event.altKey = true; + event.ctrlKey = true; + event.getModifierState = (name) => name === "AltGraph"; + } else throw new Error(`${modifier} is not reachable on macOS`); + } else { + if (modifier === "Ctrl" || modifier === "Control") event.ctrlKey = true; + else if (modifier === "Alt") event.altKey = true; + else if (modifier === "Meta") event.metaKey = true; + else if (modifier === "Shift") event.shiftKey = true; + else if (modifier === "AltGraph") { + event.altKey = true; + event.ctrlKey = true; + event.getModifierState = (name) => name === "AltGraph"; + } else throw new Error(`${modifier} is not reachable on Windows`); + } + } + return event; +} + +function contextForScope(scope: ActionShortcutScope): EditShortcutContext { + switch (scope) { + case "global": return { kind: "application" }; + case "timeline": return { kind: "timeline" }; + case "timeline_ruler": return { kind: "timeline_ruler" }; + case "track_control_panel": return { kind: "track_control_panel" }; + case "mixer": return { kind: "mixer" }; + case "pitch_editor": return { kind: "pitch_editor" }; + case "piano_roll": return { kind: "piano_roll", sessionId: "exhaustive-profile" }; + case "automation": return { kind: "automation" }; + case "browser": return { kind: "browser" }; + case "plugin": return { kind: "plugin", sessionId: "exhaustive-profile" }; + case "modal": return { kind: "modal" }; + // Contextual commands are tried whenever a concrete editor context is active. + case "contextual": return { kind: "timeline" }; + } +} + +function activateCondition(action: ActionDef): void { + const state = useDAWStore.getState(); + const transport = { + ...state.transport, + isPlaying: action.shortcutWhen === "transport_running", + isRecording: false, + }; + useDAWStore.setState({ + transport, + stepInputEnabled: action.shortcutWhen === "step_input_enabled", + }); +} + +function pointerModifierCombinations(): Array<{ + combination: MouseModifierCombination; + event: PointerModifierEventLike; +}> { + const logical = ["primary", "secondary", "alt", "shift"] as const; + return Array.from({ length: 16 }, (_, bits) => { + const active = logical.filter((_, index) => Boolean(bits & (1 << index))); + return { + combination: (active.length === 0 ? "none" : active.join("+")) as MouseModifierCombination, + event: {}, + }; + }); +} + +function rawPointerEvent( + combination: MouseModifierCombination, + platform: ShortcutPlatform, +): PointerModifierEventLike { + const modifiers = new Set(combination === "none" ? [] : combination.split("+")); + return { + ctrlKey: platform === "macos" ? modifiers.has("secondary") : modifiers.has("primary"), + metaKey: platform === "macos" ? modifiers.has("primary") : modifiers.has("secondary"), + altKey: modifiers.has("alt"), + shiftKey: modifiers.has("shift"), + }; +} + +function rawWheelModifierCombinations(platform: ShortcutPlatform): WheelEventLike[] { + return Array.from({ length: 16 }, (_, bits) => { + const primary = Boolean(bits & 1); + const secondary = Boolean(bits & 2); + return { + deltaX: 30, + deltaY: 120, + ctrlKey: platform === "macos" ? secondary : primary, + metaKey: platform === "macos" ? primary : secondary, + altKey: Boolean(bits & 4), + shiftKey: Boolean(bits & 8), + clientX: 41, + clientY: 73, + }; + }); +} + +function ruleMatches( + rule: WheelBehaviorRule, + surface: WheelSurface, + subtarget: WheelSubtarget, + event: WheelEventLike, + platform: WheelPlatform, + device: WheelInputDevice, +): boolean { + if (rule.surface !== surface) return false; + if (rule.subtargets && !rule.subtargets.includes(subtarget)) return false; + if (rule.devices && !rule.devices.includes(device)) return false; + const normalized = { + primary: platform === "macos" ? Boolean(event.metaKey) : Boolean(event.ctrlKey), + secondary: platform === "macos" ? Boolean(event.ctrlKey) : Boolean(event.metaKey), + alt: Boolean(event.altKey), + shift: Boolean(event.shiftKey), + }; + return Object.entries(rule.modifiers ?? {}).every( + ([name, expected]) => normalized[name as keyof typeof normalized] === expected, + ); +} + +describe("exhaustive keyboard, wheel, and pointer profiles", () => { + const original = { + keyboardShortcutProfileId: useDAWStore.getState().keyboardShortcutProfileId, + customShortcuts: useDAWStore.getState().customShortcuts, + tracks: useDAWStore.getState().tracks, + selectedClipId: useDAWStore.getState().selectedClipId, + selectedClipIds: useDAWStore.getState().selectedClipIds, + globalLocked: useDAWStore.getState().globalLocked, + lockSettings: useDAWStore.getState().lockSettings, + transport: useDAWStore.getState().transport, + stepInputEnabled: useDAWStore.getState().stepInputEnabled, + }; + + beforeEach(() => { + resetShortcutContextForTests(); + useDAWStore.setState({ customShortcuts: {} }); + }); + + afterEach(() => { + resetShortcutContextForTests(); + useDAWStore.setState(original); + vi.restoreAllMocks(); + }); + + it("dispatches every effective built-in binding to its intended action on macOS and Windows", () => { + const registeredIds = new Set(getRegisteredActions().map((action) => action.id)); + let verifiedBindings = 0; + + for (const profile of KEYBOARD_SHORTCUT_PROFILES) { + for (const configuredActionId of Object.keys(profile.bindings)) { + expect( + registeredIds.has(configuredActionId), + `${profile.id} binds missing action ${configuredActionId}`, + ).toBe(true); + } + + for (const platform of TEST_PLATFORMS) { + useDAWStore.setState({ + keyboardShortcutProfileId: profile.id, + customShortcuts: {}, + }); + const actions = getRegisteredActions(); + for (const action of actions) { + const bindings = getEffectiveActionShortcuts(action, platform); + for (const binding of bindings) { + const event = eventForBinding(binding, platform); + expect( + shortcutMatchesEvent(event, binding, platform), + `${profile.id}/${platform}/${action.id}: ${binding} is not physically synthesizable`, + ).toBe(true); + activateCondition(action); + const canHandle = !action.canHandleShortcut || action.canHandleShortcut(); + + for (const scope of getActionShortcutScopes(action, profile.id)) { + resetShortcutContextForTests(); + activateShortcutContext(contextForScope(scope)); + const controlled = resolveRegistryShortcutAction(event, platform, { + canHandleAction: (candidate) => candidate.id === action.id, + }); + expect( + controlled?.action.id, + `${profile.id}/${platform}/${scope}/${binding}: controlled availability`, + ).toBe(action.id); + const resolved = resolveRegistryShortcutAction(event, platform); + if (canHandle) { + expect( + resolved?.action.id, + `${profile.id}/${platform}/${scope}/${binding}`, + ).toBe(action.id); + } else { + expect( + resolved?.action.id, + `${profile.id}/${platform}/${scope}/${binding}: unavailable action consumed chord`, + ).not.toBe(action.id); + } + + const preventDefault = vi.fn(); + const executed: string[] = []; + const handled = dispatchGlobalShortcut( + { ...event, preventDefault }, + platform, + { executeAction: (matched) => executed.push(matched.id) }, + ); + if (canHandle) { + expect(handled, `${profile.id}/${platform}/${scope}/${binding}`).toBe(true); + expect(preventDefault, `${profile.id}/${platform}/${scope}/${binding}`).toHaveBeenCalled(); + } + // canHandle/repeat/debounce guards may deliberately suppress the + // body, but they must never execute a different action. + expect(executed, `${profile.id}/${platform}/${scope}/${binding}`).toSatisfy( + (ids: string[]) => ids.length === 0 + || (ids.length === 1 && ids[0] === resolved?.action.id), + ); + verifiedBindings += 1; + } + } + } + } + } + + expect(verifiedBindings).toBeGreaterThan(2_000); + }, 120_000); + + it("keeps explicit profile unbinds unassigned and unable to reclaim their factory key", () => { + for (const profile of KEYBOARD_SHORTCUT_PROFILES) { + for (const platform of TEST_PLATFORMS) { + useDAWStore.setState({ keyboardShortcutProfileId: profile.id, customShortcuts: {} }); + for (const [actionId] of Object.entries(profile.bindings)) { + const configured = getProfileActionBindings(profile.id, actionId, platform); + if (configured === undefined || configured.length > 0) continue; + const action = getRegisteredAction(actionId); + expect(action, `${profile.id}: missing ${actionId}`).toBeDefined(); + if (!action) continue; + expect(getEffectiveActionShortcuts(action, platform), `${profile.id}/${platform}/${actionId}`).toEqual([]); + for (const factoryBinding of [action.shortcut, ...(action.shortcutAliases ?? [])]) { + if (!factoryBinding || factoryBinding.includes("(")) continue; + let event: SyntheticGlobalShortcutPayload; + try { + event = eventForBinding(factoryBinding, platform); + } catch { + continue; + } + for (const scope of getActionShortcutScopes(action, profile.id)) { + resetShortcutContextForTests(); + activateShortcutContext(contextForScope(scope)); + const resolved = resolveRegistryShortcutAction(event, platform); + expect( + resolved?.action.id, + `${profile.id}/${platform}/${scope}: ${actionId} leaked through ${factoryBinding}`, + ).not.toBe(actionId); + const executed: string[] = []; + dispatchGlobalShortcut(event, platform, { + executeAction: (matched) => executed.push(matched.id), + }); + expect( + executed, + `${profile.id}/${platform}/${scope}: dispatched unbound ${actionId}`, + ).not.toContain(actionId); + } + } + } + } + } + }, 30_000); + + it("makes every registered action custom-bindable in every declared scope on both platforms", () => { + let verifiedRoutes = 0; + for (const platform of TEST_PLATFORMS) { + const binding = platform === "macos" + ? "Command+Shift+F11" + : "Control+Shift+F11"; + const event: SyntheticGlobalShortcutPayload = { + key: "F11", + code: "F11", + shiftKey: true, + metaKey: platform === "macos", + ctrlKey: platform === "windows", + source: "custom-bindability-matrix", + }; + + for (const action of getRegisteredActions()) { + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { [action.id]: binding }, + }); + activateCondition(action); + expect(getEffectiveActionShortcuts(action, platform), `${platform}/${action.id}`) + .toEqual([binding]); + + for (const scope of getActionShortcutScopes(action)) { + resetShortcutContextForTests(); + activateShortcutContext(contextForScope(scope)); + const controlledAvailability = (candidate: ActionDef) => candidate.id === action.id; + expect(resolveRegistryShortcutAction(event, platform, { + canHandleAction: controlledAvailability, + })?.action.id, `${platform}/${scope}/${action.id}`).toBe(action.id); + + const executed: string[] = []; + const preventDefault = vi.fn(); + expect(dispatchGlobalShortcut( + { ...event, preventDefault }, + platform, + { + canHandleAction: controlledAvailability, + executeAction: (candidate) => executed.push(candidate.id), + }, + ), `${platform}/${scope}/${action.id}`).toBe(true); + expect(preventDefault, `${platform}/${scope}/${action.id}`).toHaveBeenCalled(); + expect(executed, `${platform}/${scope}/${action.id}`).toSatisfy( + (ids: string[]) => ids.length === 0 + || (ids.length === 1 && ids[0] === action.id), + ); + verifiedRoutes += 1; + } + } + } + + expect(verifiedRoutes).toBeGreaterThan(700); + }, 120_000); + + it("gives an active surface action precedence over a same-key global action", () => { + const split = getRegisteredAction("edit.splitAtCursor"); + const play = getRegisteredAction("transport.play"); + expect(split).toBeDefined(); + expect(play).toBeDefined(); + const track = createDefaultTrack("precedence-track", "Precedence", "#123456", "audio", []); + track.clips = [{ + id: "precedence-clip", + filePath: "C:/precedence.wav", + name: "Precedence", + startTime: 0, + duration: 2, + offset: 0, + color: "#123456", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }]; + useDAWStore.setState((state) => ({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { + "edit.splitAtCursor": "Code:KeyK", + "transport.play": "Code:KeyK", + }, + tracks: [track], + selectedClipId: "precedence-clip", + selectedClipIds: ["precedence-clip"], + globalLocked: false, + lockSettings: { ...state.lockSettings, items: false }, + transport: { ...state.transport, currentTime: 1 }, + })); + const event = { key: "k", code: "KeyK", source: "precedence-test" }; + + activateShortcutContext({ kind: "timeline" }); + expect(resolveRegistryShortcutAction(event, "windows")?.action.id).toBe("edit.splitAtCursor"); + + activateShortcutContext({ kind: "application" }); + expect(resolveRegistryShortcutAction(event, "windows")?.action.id).toBe("transport.play"); + }); + + it("skips an unavailable same-scope owner and dispatches the next valid match", () => { + const track = createDefaultTrack("fallback-track", "Fallback", "#654321", "audio", []); + track.clips = [{ + id: "fallback-clip", + filePath: "C:/fallback.wav", + name: "Fallback", + startTime: 0, + duration: 2, + offset: 0, + color: "#654321", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }]; + useDAWStore.setState((state) => ({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { + "edit.cut": "Code:KeyK", + "edit.splitAtCursor": "Code:KeyK", + }, + tracks: [track], + selectedClipId: null, + selectedClipIds: [], + globalLocked: false, + lockSettings: { ...state.lockSettings, items: false }, + transport: { ...state.transport, currentTime: 1 }, + })); + activateShortcutContext({ kind: "timeline" }); + const event = { key: "k", code: "KeyK", source: "can-handle-fallback" }; + expect(resolveRegistryShortcutAction(event, "windows")?.action.id).toBe("edit.splitAtCursor"); + + const executed: string[] = []; + expect(dispatchGlobalShortcut(event, "windows", { + executeAction: (action) => executed.push(action.id), + })).toBe(true); + expect(executed).toEqual(["edit.splitAtCursor"]); + }); + + it("retains active component-handler precedence over registry actions", () => { + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { "edit.splitAtCursor": "Code:KeyK" }, + }); + const surfaceHandler = vi.fn(() => "handled" as const); + registerShortcutSurface({ kind: "timeline" }, surfaceHandler); + activateShortcutContext({ kind: "timeline" }); + const executed: string[] = []; + + expect(dispatchGlobalShortcut( + { key: "k", code: "KeyK", source: "component-precedence" }, + "windows", + { executeAction: (action) => executed.push(action.id) }, + )).toBe(true); + expect(surfaceHandler).toHaveBeenCalledOnce(); + expect(executed).toEqual([]); + }); + + it("reaches every wheel rule and checks every surface, subtarget, device, modifier, and platform", () => { + for (const profile of KEYBOARD_SHORTCUT_PROFILES) { + for (const platform of TEST_PLATFORMS) { + const behavior = getMouseBehaviorProfile(profile.id, platform); + const rules = behavior.wheel.rules; + const ids = rules.map((rule) => rule.id); + expect(new Set(ids).size, `${profile.id}/${platform}: duplicate wheel rule ID`).toBe(ids.length); + const reached = new Set(); + + for (const surface of WHEEL_SURFACES) { + for (const subtarget of WHEEL_SUBTARGETS) { + for (const device of WHEEL_DEVICES) { + for (const event of rawWheelModifierCombinations(platform)) { + const matches = rules.filter((rule) => ruleMatches( + rule, + surface, + subtarget, + event, + platform, + device, + )); + const expected = matches[0]; + const resolved = resolveWheelGesture(event, { + surface, + subtarget, + platform, + deviceHint: device, + hoveredTargetId: "exhaustive-target", + }, behavior.wheel); + + expect( + resolved.ruleId, + `${profile.id}/${platform}/${surface}/${subtarget}/${device}`, + ).toBe(expected?.id ?? null); + if (!expected) { + expect(resolved).toMatchObject({ + matched: false, + operation: "native-scroll", + target: "native", + preventDefault: false, + stopPropagation: false, + }); + continue; + } + + reached.add(expected.id); + expect(resolved).toMatchObject({ + profileId: behavior.wheel.id, + matched: true, + operation: expected.operation, + target: expected.target, + preventDefault: expected.preventDefault, + stopPropagation: expected.stopPropagation, + precision: expected.precision ?? "normal", + anchor: { + kind: expected.anchor ?? "none", + }, + }); + } + } + } + } + + expect( + [...reached].sort(), + `${profile.id}/${platform}: shadowed or unreachable wheel rule`, + ).toEqual([...ids].sort()); + } + } + }, 120_000); + + it("resolves all 16 pointer combinations in every context without vendor fallthrough", () => { + const combinations = pointerModifierCombinations(); + for (const profile of KEYBOARD_SHORTCUT_PROFILES) { + for (const platform of TEST_PLATFORMS) { + const behavior = getMouseBehaviorProfile(profile.id, platform); + for (const context of MOUSE_MODIFIER_CONTEXTS) { + const mapping = behavior.modifiers.mappings[context] as Readonly>; + for (const { combination } of combinations) { + const resolved = resolveMouseModifier( + rawPointerEvent(combination, platform), + context, + { platform, profile: behavior.modifiers }, + ); + expect(resolved.profileId).toBe(profile.id); + expect(resolved.context).toBe(context); + expect(resolved.modifiers.combination).toBe(combination); + expect(resolved.isNoop).toBe(resolved.action === "none"); + if (profile.id !== "openstudio") { + expect( + Object.prototype.hasOwnProperty.call(mapping, combination), + `${profile.id}/${platform}/${context}/${combination}: implicit vendor fallback`, + ).toBe(true); + expect(resolved).toMatchObject({ + source: "profile", + matchKind: "exact", + matchedCombination: combination, + matched: true, + }); + } + } + } + } + } + }); +}); diff --git a/frontend/src/__tests__/externalURLSafety.test.ts b/frontend/src/__tests__/externalURLSafety.test.ts new file mode 100644 index 0000000..0629d81 --- /dev/null +++ b/frontend/src/__tests__/externalURLSafety.test.ts @@ -0,0 +1,104 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + isAllowedExternalBrowserURL, + nativeBridge, +} from "../services/NativeBridge"; + +const nativeSource = readFileSync( + new URL("../../../Source/MainComponent.cpp", import.meta.url), + "utf8", +).replace(/\r\n?/g, "\n"); +const bridgeSource = readFileSync( + new URL("../services/NativeBridge.ts", import.meta.url), + "utf8", +).replace(/\r\n?/g, "\n"); +const aiSetupSource = readFileSync( + new URL("../components/AiToolsSetupModal.tsx", import.meta.url), + "utf8", +).replace(/\r\n?/g, "\n"); + +describe("external URL safety policy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + "https://www.tone3000.com/tones/example-1", + "http://127.0.0.1:5183/help", + " HTTPS://example.com/path?q=1 ", + ])("allows an absolute HTTP(S) URL: %s", (url) => { + expect(isAllowedExternalBrowserURL(url)).toBe(true); + }); + + it.each([ + "javascript:alert(document.domain)", + "data:text/html,", + "file:///C:/Users/example/log.txt", + "mailto:support@example.com", + "shell:AppsFolder", + "ms-settings:privacy-microphone", + "//tone3000.com/tones/example-1", + "/relative/help", + "https:example.com", + "https://exa\tmple.com", + "", + ])("rejects a non-web or malformed external URL: %s", (url) => { + expect(isAllowedExternalBrowserURL(url)).toBe(false); + }); + + it("does not forward a rejected URL to the web fallback", async () => { + const open = vi.fn(); + vi.stubGlobal("window", { open }); + + await expect(nativeBridge.openExternalURL("javascript:alert(1)")) + .resolves.toBe(false); + expect(open).not.toHaveBeenCalled(); + }); + + it("normalizes and forwards an allowed URL to the web fallback", async () => { + const open = vi.fn(); + vi.stubGlobal("window", { open }); + + await expect(nativeBridge.openExternalURL(" https://example.com/docs ")) + .resolves.toBe(true); + expect(open).toHaveBeenCalledWith( + "https://example.com/docs", + "_blank", + "noopener,noreferrer", + ); + }); + + it("mirrors the HTTP(S)-only gate in the native bridge before launch", () => { + expect(nativeSource).toContain("bool isAllowedExternalBrowserURL(juce::String rawURL)"); + expect(nativeSource).toContain('rawURL.startsWithIgnoreCase("https://")'); + expect(nativeSource).toContain('rawURL.startsWithIgnoreCase("http://")'); + expect(nativeSource).toContain("if (! isAllowedExternalBrowserURL(url))"); + + const registrationStart = nativeSource.indexOf('.withNativeFunction ("openExternalURL"'); + const registrationEnd = nativeSource.indexOf('.withNativeFunction ("revealLocalPath"', registrationStart); + const registration = nativeSource.slice(registrationStart, registrationEnd); + expect(registration).toContain("launchInDefaultBrowser()"); + expect(registration.indexOf("isAllowedExternalBrowserURL")) + .toBeLessThan(registration.indexOf("launchInDefaultBrowser")); + }); + + it("routes install-log access through a reveal-only native API", async () => { + expect(aiSetupSource).toContain("nativeBridge.revealLocalPath(installLogPath)"); + expect(aiSetupSource).not.toContain("openExternalURL(toFileUrl"); + expect(bridgeSource).toContain("async revealLocalPath(path: string): Promise"); + + const registrationStart = nativeSource.indexOf('.withNativeFunction ("revealLocalPath"'); + const registrationEnd = nativeSource.indexOf('.withNativeFunction ("createTONE3000AuthRequest"', registrationStart); + const registration = nativeSource.slice(registrationStart, registrationEnd); + expect(registration).toContain("juce::File::isAbsolutePath(path)"); + expect(registration).toContain("localPath.existsAsFile()"); + expect(registration).toContain("localPath.revealToUser()"); + expect(registration).not.toContain("startAsProcess"); + expect(registration).not.toContain("launchInDefaultBrowser"); + + await expect(nativeBridge.revealLocalPath("C:\\OpenStudio\\install.log")) + .resolves.toBe(false); + }); +}); diff --git a/frontend/src/__tests__/frontendStyleArchitecture.test.ts b/frontend/src/__tests__/frontendStyleArchitecture.test.ts new file mode 100644 index 0000000..b7c8af8 --- /dev/null +++ b/frontend/src/__tests__/frontendStyleArchitecture.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +const sourceFiles = import.meta.glob( + ["../**/*.css", "../**/*.ts", "../**/*.tsx", "!../__tests__/**"], + { eager: true, query: "?raw", import: "default" }, +) as Record; + +describe("frontend style architecture", () => { + it("does not use forced cascade declarations", () => { + const forbiddenToken = "!" + "important"; + const offenders = Object.entries(sourceFiles) + .filter(([, source]) => source.includes(forbiddenToken)) + .map(([path]) => path); + + expect(offenders).toEqual([]); + }); + + it("keeps stylesheets out of TypeScript template strings and JSX style tags", () => { + const stylesheetConstant = /const\s+[A-Z][A-Z0-9_]*(?:CSS|STYLES?)\s*=\s*`/; + const offenders = Object.entries(sourceFiles).flatMap(([path, source]) => { + if (!path.endsWith(".tsx") && !path.endsWith(".ts")) return []; + return /)/.test(source) || stylesheetConstant.test(source) + ? [path] + : []; + }); + + expect(offenders).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/globalLockStructuralActions.test.ts b/frontend/src/__tests__/globalLockStructuralActions.test.ts new file mode 100644 index 0000000..af7b1ec --- /dev/null +++ b/frontend/src/__tests__/globalLockStructuralActions.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + useDAWStore, +} from "../store/useDAWStore"; +import { createTrackOfType } from "../utils/trackCreation"; + +const originalState = useDAWStore.getState(); + +function clip(id = "clip", overrides: Partial = {}): AudioClip { + return { + id, + filePath: `C:/audio/${id}.wav`, + name: id, + startTime: 0, + duration: 1, + offset: 0, + color: "#224466", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + ...overrides, + }; +} + +function editableTrack() { + const track = createDefaultTrack("track", "Track", "#224466", "audio", []); + track.clips = [clip()]; + return track; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState({ + tracks: [], + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + selectedClipId: null, + selectedClipIds: [], + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + showPluginBrowser: false, + pluginBrowserTrackId: null, + canUndo: false, + canRedo: false, + syncClipsWithBackend: vi.fn(async () => undefined), + syncMIDITrackToBackend: vi.fn(async () => undefined), + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("Global Lock structural action contract", () => { + it.each([ + "insert.audioTrack", + "insert.midiTrack", + "insert.instrumentTrack", + "insert.aiTrack", + "insert.quickAddInstrument", + "insert.folderTrack", + "insert.multipleTracks", + "insert.busTrack", + "track.setSelectedColor", + "track.consolidateSelected", + "track.toggleSelectedFreeze", + "track.renderSelectedInPlace", + "clip.renderSelectedInPlace", + "insert.bus", + ])("does not advertise %s while Global Lock is enabled", (actionId) => { + const track = editableTrack(); + useDAWStore.setState({ + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + selectedClipId: track.clips[0].id, + selectedClipIds: [track.clips[0].id], + globalLocked: true, + }); + + expect(getRegisteredAction(actionId)?.canHandleShortcut?.()).toBe(false); + }); + + it("rejects direct track creation/color entry points without phantom UI or history", async () => { + const track = editableTrack(); + useDAWStore.setState({ + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + globalLocked: true, + }); + + useDAWStore.getState().addTrack({ id: "new", name: "New", type: "audio" }); + useDAWStore.getState().createFolderTrack("Folder"); + useDAWStore.getState().setTracksColorWithUndo([track.id], "#ff0000"); + const created = await createTrackOfType("instrument", { openInstrumentBrowser: true }); + + const state = useDAWStore.getState(); + expect(created).toBeNull(); + expect(state.tracks).toHaveLength(1); + expect(state.tracks[0].color).toBe("#224466"); + expect(state.selectedTrackIds).toEqual([track.id]); + expect(state.showPluginBrowser).toBe(false); + expect(state.pluginBrowserTrackId).toBeNull(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it.each([ + ["track.consolidateSelected", "items"], + ["track.toggleSelectedFreeze", "items"], + ["track.renderSelectedInPlace", "items"], + ["clip.renderSelectedInPlace", "items"], + ] as const)("does not advertise %s under the %s lock", (actionId, _lockKind) => { + const track = editableTrack(); + useDAWStore.setState({ + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + selectedClipId: "clip", + selectedClipIds: ["clip"], + lockSettings: { items: true, envelopes: false, timeSelection: false, markers: false }, + }); + expect(getRegisteredAction(actionId)?.canHandleShortcut?.()).toBe(false); + }); + + it.each([ + "track.consolidateSelected", + "track.renderSelectedInPlace", + "clip.renderSelectedInPlace", + ])("does not advertise %s for a frozen target", (actionId) => { + const track = editableTrack(); + track.frozen = true; + useDAWStore.setState({ + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + selectedClipId: "clip", + selectedClipIds: ["clip"], + }); + expect(getRegisteredAction(actionId)?.canHandleShortcut?.()).toBe(false); + }); + + it.each([ + "track.consolidateSelected", + "track.renderSelectedInPlace", + "clip.renderSelectedInPlace", + ])("does not advertise %s for a clip-locked target", (actionId) => { + const track = editableTrack(); + track.clips[0].locked = true; + useDAWStore.setState({ + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + selectedClipId: "clip", + selectedClipIds: ["clip"], + }); + expect(getRegisteredAction(actionId)?.canHandleShortcut?.()).toBe(false); + }); + + it("rolls back a batch add if Global Lock engages during the native add", async () => { + const add = deferred(); + vi.spyOn(nativeBridge, "addTrack").mockReturnValueOnce(add.promise); + const removeTrack = vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + + const pending = useDAWStore.getState().addTracksBatch([ + { id: "late-track", name: "Late", type: "audio" }, + ]); + useDAWStore.setState({ globalLocked: true }); + add.resolve("late-track"); + + await expect(pending).resolves.toEqual([]); + expect(removeTrack).toHaveBeenCalledWith("late-track"); + expect(useDAWStore.getState().tracks).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("rolls back a bus if Global Lock engages during native creation", async () => { + const source = editableTrack(); + const add = deferred(); + vi.spyOn(nativeBridge, "addTrack").mockReturnValueOnce(add.promise); + const removeTrack = vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + const addTrackSend = vi.spyOn(nativeBridge, "addTrackSend").mockResolvedValue(0); + useDAWStore.setState({ + tracks: [source], + selectedTrackId: source.id, + selectedTrackIds: [source.id], + }); + + const pending = Promise.resolve(useDAWStore.getState().createBusFromSelectedTracks()); + useDAWStore.setState({ globalLocked: true }); + add.resolve("bus"); + + await expect(pending).resolves.toBe(false); + expect(addTrackSend).not.toHaveBeenCalled(); + expect(removeTrack).toHaveBeenCalledTimes(1); + expect(useDAWStore.getState().tracks).toEqual([source]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("rolls back native freeze if Global Lock engages while rendering", async () => { + const track = editableTrack(); + const freeze = deferred<{ success: boolean; filePath: string; duration: number; sampleRate: number }>(); + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "freezeTrack").mockReturnValueOnce(freeze.promise); + const unfreeze = vi.spyOn(nativeBridge, "unfreezeTrack").mockResolvedValue(true); + useDAWStore.setState({ tracks: [track], selectedTrackId: track.id, selectedTrackIds: [track.id] }); + + const pending = useDAWStore.getState().toggleSelectedTracksFreeze(); + await vi.waitFor(() => expect(nativeBridge.freezeTrack).toHaveBeenCalled()); + useDAWStore.setState({ globalLocked: true }); + freeze.resolve({ + success: true, + filePath: "C:/freeze/track.wav", + duration: 1, + sampleRate: 48_000, + }); + + await expect(pending).resolves.toBe(false); + expect(unfreeze).toHaveBeenCalledWith(track.id); + expect(useDAWStore.getState().tracks[0]).toBe(track); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it.each([ + ["consolidate", (trackId: string) => useDAWStore.getState().consolidateTrack(trackId)], + ["render-track", (trackId: string) => useDAWStore.getState().renderTrackInPlace(trackId)], + ["render-clip", (_trackId: string) => useDAWStore.getState().renderClipInPlace("clip")], + ] as const)("aborts %s if Global Lock engages while its dialog is open", async (_label, invoke) => { + const track = editableTrack(); + const dialog = deferred(); + vi.spyOn(nativeBridge, "showRenderSaveDialog").mockReturnValueOnce(dialog.promise); + const render = vi.spyOn(nativeBridge, "renderProject").mockResolvedValue(true); + useDAWStore.setState({ tracks: [track] }); + + const pending = invoke(track.id); + useDAWStore.setState({ globalLocked: true }); + dialog.resolve("C:/renders/result.wav"); + await pending; + + expect(render).not.toHaveBeenCalled(); + expect(useDAWStore.getState().tracks).toEqual([track]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("still replays an already-created track command after Global Lock is enabled", async () => { + vi.spyOn(nativeBridge, "addTrack").mockResolvedValue("new"); + vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + await useDAWStore.getState().addTracksBatch([{ id: "new", name: "New", type: "audio" }]); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["new"]); + + useDAWStore.setState({ globalLocked: true }); + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks).toEqual([])); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["new"])); + }); + + it("still replays an already-created freeze command after Global Lock is enabled", async () => { + const track = editableTrack(); + const freeze = vi.spyOn(nativeBridge, "freezeTrack").mockResolvedValue({ + success: true, + filePath: "C:/freeze/track.wav", + duration: 1, + sampleRate: 48_000, + }); + const unfreeze = vi.spyOn(nativeBridge, "unfreezeTrack").mockResolvedValue(true); + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + useDAWStore.setState({ tracks: [track], selectedTrackId: track.id, selectedTrackIds: [track.id] }); + + await expect(useDAWStore.getState().toggleSelectedTracksFreeze()).resolves.toBe(true); + useDAWStore.setState({ globalLocked: true }); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].frozen).toBe(false); + await vi.waitFor(() => expect(unfreeze).toHaveBeenCalled()); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].frozen).toBe(true); + await vi.waitFor(() => expect(freeze).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/frontend/src/__tests__/hotkeyFocusRegression.test.ts b/frontend/src/__tests__/hotkeyFocusRegression.test.ts new file mode 100644 index 0000000..49ebc95 --- /dev/null +++ b/frontend/src/__tests__/hotkeyFocusRegression.test.ts @@ -0,0 +1,294 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import mainSource from "../../../Source/Main.cpp?raw"; +import { nativeBridge } from "../services/NativeBridge"; +import { useDAWStore } from "../store/useDAWStore"; +import { dispatchGlobalShortcut } from "../utils/globalShortcutDispatcher"; +import { resetShortcutContextForTests } from "../utils/shortcutContext"; + +const originalState = { + keyboardShortcutProfileId: useDAWStore.getState().keyboardShortcutProfileId, + activeCustomKeyboardProfileId: useDAWStore.getState().activeCustomKeyboardProfileId, + customShortcuts: useDAWStore.getState().customShortcuts, + transport: useDAWStore.getState().transport, + recordSession: useDAWStore.getState().recordSession, + play: useDAWStore.getState().play, + stop: useDAWStore.getState().stop, + toggleLoop: useDAWStore.getState().toggleLoop, +}; + +let syntheticNow = 10_000_000; + +function setTransportState(state: "stopped" | "playing" | "recording") { + const current = useDAWStore.getState(); + useDAWStore.setState({ + transport: { + ...current.transport, + isPlaying: state !== "stopped", + isPaused: false, + isRecording: state === "recording", + }, + recordSession: state === "recording" + ? { id: "focus-regression-recording", startTime: 0, trackIds: [] } + : null, + }); +} + +function dispatchSpace(options: { + role?: string; + editable?: boolean; + nonTextControl?: boolean; + repeat?: boolean; + source?: "browser" | "pluginWindow"; +} = {}) { + const preventDefault = vi.fn(); + const handled = dispatchGlobalShortcut({ + key: " ", + code: "Space", + source: options.source ?? "browser", + repeat: options.repeat, + targetIsEditable: options.editable, + targetIsNonTextControl: options.nonTextControl, + preventDefault, + }, "windows", { role: options.role ?? "main" }); + return { handled, preventDefault }; +} + +beforeEach(() => { + syntheticNow += 1_000; + vi.spyOn(Date, "now").mockImplementation(() => syntheticNow); + resetShortcutContextForTests(); + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + transport: { + ...originalState.transport, + isPlaying: false, + isPaused: false, + isRecording: false, + }, + recordSession: null, + play: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + toggleLoop: vi.fn(), + }); +}); + +afterEach(() => { + resetShortcutContextForTests(); + useDAWStore.setState(originalState); + vi.restoreAllMocks(); +}); + +describe("hotkey focus and window regression contract", () => { + it.each([ + ["stopped", "play"], + ["playing", "stop"], + ["recording", "stop"], + ] as const)( + "reserves active-profile Space for transport from a focused non-text control while %s", + (transportState, expectedAction) => { + const play = vi.fn().mockResolvedValue(undefined); + const stop = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ play, stop }); + setTransportState(transportState); + + const { handled, preventDefault } = dispatchSpace({ nonTextControl: true }); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(play).toHaveBeenCalledTimes(expectedAction === "play" ? 1 : 0); + expect(stop).toHaveBeenCalledTimes(expectedAction === "stop" ? 1 : 0); + }, + ); + + it.each([ + ["main maximize button", "main"], + ["NAM Rack control", "pluginEditor"], + ["detached MIDI control", "midiEditor"], + ["detached Mixer control", "mixer"], + ])("claims Space instead of leaving it with a focused %s", (_label, role) => { + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + const { handled, preventDefault } = dispatchSpace({ role, nonTextControl: true }); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + if (role === "main") { + expect(useDAWStore.getState().play).toHaveBeenCalledOnce(); + expect(publish).not.toHaveBeenCalled(); + } else { + expect(publish).toHaveBeenCalledOnce(); + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ + command: "transport.toggle", + })); + expect(useDAWStore.getState().play).not.toHaveBeenCalled(); + } + }); + + it("returns Space to a focused control when Play is explicitly unbound", () => { + useDAWStore.setState({ + customShortcuts: { "transport.play": { common: [] } }, + }); + const { handled, preventDefault } = dispatchSpace({ nonTextControl: true }); + + expect(handled).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + expect(useDAWStore.getState().play).not.toHaveBeenCalled(); + }); + + it("uses a remapped Play key from a focused control and leaves Space native", () => { + useDAWStore.setState({ + customShortcuts: { "transport.play": { common: ["P"] } }, + }); + + const space = dispatchSpace({ nonTextControl: true }); + expect(space.handled).toBe(false); + expect(space.preventDefault).not.toHaveBeenCalled(); + + const preventDefault = vi.fn(); + expect(dispatchGlobalShortcut({ + key: "p", + code: "KeyP", + source: "browser", + targetIsNonTextControl: true, + preventDefault, + }, "windows", { role: "main" })).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(useDAWStore.getState().play).toHaveBeenCalledOnce(); + }); + + it("does not let another global action assigned to Space steal native control activation", () => { + useDAWStore.setState({ + customShortcuts: { + "transport.play": { common: [] }, + "transport.loop": { common: ["Space"] }, + }, + }); + const { handled, preventDefault } = dispatchSpace({ nonTextControl: true }); + + expect(handled).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + expect(useDAWStore.getState().toggleLoop).not.toHaveBeenCalled(); + }); + + it("preserves stopped editable Space but consumes the active Play binding while running", () => { + const stop = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ stop }); + + setTransportState("stopped"); + const stopped = dispatchSpace({ editable: true }); + expect(stopped.handled).toBe(false); + expect(stopped.preventDefault).not.toHaveBeenCalled(); + + syntheticNow += 1_000; + setTransportState("playing"); + const playing = dispatchSpace({ editable: true }); + expect(playing.handled).toBe(true); + expect(playing.preventDefault).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + + syntheticNow += 1_000; + setTransportState("recording"); + const recording = dispatchSpace({ editable: true }); + expect(recording.handled).toBe(true); + expect(recording.preventDefault).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledTimes(2); + }); + + it("honors unbound and remapped Play inside an editable field", () => { + const stop = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ + stop, + customShortcuts: { "transport.play": { common: [] } }, + }); + setTransportState("playing"); + + const unboundSpace = dispatchSpace({ editable: true }); + expect(unboundSpace.handled).toBe(false); + expect(stop).not.toHaveBeenCalled(); + + useDAWStore.setState({ + customShortcuts: { "transport.play": { common: ["P"] } }, + }); + const stoppedEditableP = vi.fn(); + setTransportState("stopped"); + expect(dispatchGlobalShortcut({ + key: "p", + code: "KeyP", + source: "browser", + targetIsEditable: true, + preventDefault: stoppedEditableP, + }, "windows", { role: "main" })).toBe(false); + expect(stoppedEditableP).not.toHaveBeenCalled(); + + setTransportState("playing"); + const runningEditableP = vi.fn(); + expect(dispatchGlobalShortcut({ + key: "p", + code: "KeyP", + source: "browser", + targetIsEditable: true, + preventDefault: runningEditableP, + }, "windows", { role: "main" })).toBe(true); + expect(runningEditableP).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + }); + + it.each(["pluginEditor", "midiEditor", "mixer"])( + "forwards an editable-field stop from the detached %s exactly once", + (role) => { + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + setTransportState("recording"); + + const { handled, preventDefault } = dispatchSpace({ role, editable: true }); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(publish).toHaveBeenCalledOnce(); + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ + command: "transport.stop", + })); + }, + ); + + it("consumes key repeat without replaying transport locally or remotely", () => { + const publish = vi.spyOn(nativeBridge, "publishAppCommand").mockResolvedValue(true); + + const main = dispatchSpace({ nonTextControl: true, repeat: true }); + expect(main.handled).toBe(true); + expect(main.preventDefault).toHaveBeenCalledOnce(); + expect(useDAWStore.getState().play).not.toHaveBeenCalled(); + + const detached = dispatchSpace({ + role: "pluginEditor", + nonTextControl: true, + repeat: true, + }); + expect(detached.handled).toBe(true); + expect(detached.preventDefault).toHaveBeenCalledOnce(); + expect(publish).not.toHaveBeenCalled(); + }); + + it("deduplicates a browser/native double delivery into one transport action", () => { + const play = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ play }); + setTransportState("stopped"); + + const browser = dispatchSpace({ nonTextControl: true, source: "browser" }); + syntheticNow += 25; + const native = dispatchSpace({ source: "pluginWindow" }); + + expect(browser.handled).toBe(true); + expect(native.handled).toBe(true); + expect(browser.preventDefault).toHaveBeenCalledOnce(); + expect(native.preventDefault).toHaveBeenCalledOnce(); + expect(play).toHaveBeenCalledOnce(); + }); + + it("routes native plug-in shortcuts only to the authoritative main WebView", () => { + expect(mainSource).toMatch( + /broadcastEventToRole\s*\(\s*MainComponent::WindowRole::main\s*,\s*"nativeGlobalShortcut"/, + ); + expect(mainSource).not.toContain('broadcastEventToAll("nativeGlobalShortcut", payload)'); + }); +}); diff --git a/frontend/src/__tests__/inputProfileHelp.test.ts b/frontend/src/__tests__/inputProfileHelp.test.ts new file mode 100644 index 0000000..f7d33f2 --- /dev/null +++ b/frontend/src/__tests__/inputProfileHelp.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { useDAWStore } from "../store/useDAWStore"; +import { + getEffectiveShortcutLabel, + getTimelineWheelHelp, +} from "../utils/inputProfileHelp"; + +afterEach(() => { + useDAWStore.setState({ + customShortcuts: {}, + keyboardShortcutProfileId: "openstudio", + mouseBehaviorProfileId: "openstudio", + }); +}); + +describe("input profile help", () => { + it("describes the selected REAPER timeline rules instead of OpenStudio defaults", () => { + expect(getTimelineWheelHelp("reaper", "windows").items).toEqual([ + { gesture: "Scroll", action: "zoom the timeline" }, + { gesture: "Ctrl+Scroll", action: "resize track height" }, + { gesture: "Alt+Scroll", action: "scroll horizontally" }, + { gesture: "Ctrl+Alt+Scroll", action: "scroll vertically" }, + ]); + }); + + it("distinguishes physical Control from Command for Logic on macOS", () => { + expect(getTimelineWheelHelp("logic_pro", "macos").items).toEqual([ + { gesture: "Ctrl+Option+Scroll", action: "zoom the timeline" }, + { gesture: "Other wheel gestures", action: "use native scrolling" }, + ]); + expect(getTimelineWheelHelp("logic_pro", "windows").items[0]).toEqual({ + gesture: "Ctrl+Alt+Scroll", + action: "zoom the timeline", + }); + }); + + it("describes FL Studio wheel actions only at their exact Playlist hit targets", () => { + expect(getTimelineWheelHelp("fl_studio", "windows").items).toEqual([ + { + gesture: "Shift+Scroll over a track", + action: "reorder the hovered track", + }, + { + gesture: "Alt+Shift+Scroll over a clip", + action: "nudge the hovered clip", + }, + { + gesture: "Other wheel gestures", + action: "use native scrolling", + }, + ]); + }); + + it("does not invent GarageBand wheel behavior and keeps DP physical modifiers visible", () => { + expect(getTimelineWheelHelp("garageband", "macos").items).toEqual([ + { + gesture: "Wheel gestures", + action: "use native scrolling when no supported item-specific action matches", + }, + ]); + expect(getTimelineWheelHelp("digital_performer", "macos").items).toEqual([ + { gesture: "Option+Scroll", action: "zoom the timeline" }, + { gesture: "Ctrl+Option+Scroll", action: "resize track height" }, + { gesture: "Other wheel gestures", action: "use native scrolling" }, + ]); + expect(getTimelineWheelHelp("digital_performer", "windows").items[1]).toEqual({ + gesture: "Win+Alt+Scroll", + action: "resize track height", + }); + }); + + it("describes Cakewalk's exact Clips-pane zoom combinations", () => { + expect(getTimelineWheelHelp("cakewalk_sonar", "windows").items).toEqual([ + { gesture: "Alt+Scroll", action: "zoom the timeline" }, + { gesture: "Alt+Shift+Scroll", action: "zoom the timeline faster" }, + { gesture: "Ctrl+Alt+Scroll", action: "resize track height" }, + { gesture: "Other wheel gestures", action: "use native scrolling" }, + ]); + }); + + it("describes the new profiles without broadening their documented wheel scopes", () => { + expect(getTimelineWheelHelp("adobe_audition", "windows").items).toEqual([ + { gesture: "Scroll over the ruler", action: "zoom the timeline" }, + ]); + expect(getTimelineWheelHelp("mixcraft", "windows").items).toEqual([ + { gesture: "Scroll", action: "zoom the timeline" }, + { gesture: "Ctrl+Scroll", action: "scroll horizontally" }, + { gesture: "Shift+Scroll", action: "scroll vertically" }, + ]); + expect(getTimelineWheelHelp("waveform", "macos").items).toEqual([ + { gesture: "Scroll", action: "zoom the timeline" }, + { gesture: "Cmd+Scroll", action: "resize track height" }, + ]); + expect(getTimelineWheelHelp("renoise", "windows").items).toEqual([ + { + gesture: "Wheel gestures", + action: "use native scrolling when no supported item-specific action matches", + }, + ]); + }); + + it("names exact lane, scale, fade, and event-volume targets without generic placeholders", () => { + expect(getTimelineWheelHelp("ableton_live", "windows", 8).items).toContainEqual({ + gesture: "Alt+Scroll over an automation lane", + action: "resize the hovered automation lane", + }); + + const audacityItems = getTimelineWheelHelp("audacity", "windows", 10).items; + expect(audacityItems).toContainEqual({ + gesture: "Shift+Scroll over a waveform scale", + action: "pan the waveform scale vertically", + }); + expect(audacityItems).toContainEqual({ + gesture: "Ctrl+Shift+Scroll over a spectrogram scale", + action: "adjust the spectrogram lower dB limit", + }); + + const cubaseItems = getTimelineWheelHelp("cubase", "windows", 10).items; + expect(cubaseItems).toContainEqual({ + gesture: "Scroll over a fade handle", + action: "adjust the hovered fade length", + }); + expect(cubaseItems).toContainEqual({ + gesture: "Scroll over an event-volume handle", + action: "adjust the hovered event volume", + }); + }); + + it("labels custom and profile-owned empty bindings as unassigned", () => { + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { "transport.record": "" }, + }); + expect(getEffectiveShortcutLabel("transport.record", "Ctrl+R")).toBe("Unassigned"); + + useDAWStore.setState({ + keyboardShortcutProfileId: "pro_tools", + customShortcuts: {}, + }); + expect(getEffectiveShortcutLabel("tools.splitTool", "B")).toBe("Unassigned"); + }); +}); diff --git a/frontend/src/__tests__/inputProfileStore.test.ts b/frontend/src/__tests__/inputProfileStore.test.ts new file mode 100644 index 0000000..c281a05 --- /dev/null +++ b/frontend/src/__tests__/inputProfileStore.test.ts @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDAWStore } from "../store/useDAWStore"; +import { + MOUSE_MODIFIER_OVERRIDES_STORAGE_KEY, + loadStoredMouseModifierOverrides, + parsePersistedMouseModifierOverrides, +} from "../utils/mouseModifierPersistence"; + +const SETTINGS_KEY = "openstudio.inputProfiles.v1"; + +describe("input profile persistence", () => { + let storage: Storage; + let previousState: Pick< + ReturnType, + | "keyboardShortcutProfileId" + | "mouseBehaviorProfileId" + | "inputProfileOnboardingSeen" + | "mouseModifiers" + | "showToast" + >; + + beforeEach(() => { + const values = new Map(); + storage = { + get length() { return values.size; }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => { values.delete(key); }, + setItem: (key, value) => { values.set(key, String(value)); }, + }; + vi.stubGlobal("localStorage", storage); + const state = useDAWStore.getState(); + previousState = { + keyboardShortcutProfileId: state.keyboardShortcutProfileId, + mouseBehaviorProfileId: state.mouseBehaviorProfileId, + inputProfileOnboardingSeen: state.inputProfileOnboardingSeen, + mouseModifiers: state.mouseModifiers, + showToast: state.showToast, + }; + storage.removeItem(SETTINGS_KEY); + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + mouseBehaviorProfileId: "openstudio", + inputProfileOnboardingSeen: false, + mouseModifiers: {}, + }); + }); + + afterEach(() => { + useDAWStore.setState(previousState); + storage.removeItem(SETTINGS_KEY); + vi.unstubAllGlobals(); + }); + + it("persists keyboard and mouse profiles independently with a schema version", () => { + useDAWStore.getState().setKeyboardShortcutProfile("reaper"); + useDAWStore.getState().setMouseBehaviorProfile("logic_pro"); + + expect(useDAWStore.getState().keyboardShortcutProfileId).toBe("reaper"); + expect(useDAWStore.getState().mouseBehaviorProfileId).toBe("logic_pro"); + expect(JSON.parse(storage.getItem(SETTINGS_KEY) ?? "{}")).toMatchObject({ + schemaVersion: 1, + keyboardProfileId: "reaper", + mouseProfileId: "logic_pro", + onboardingSeen: false, + }); + }); + + it("records completion of the first-run profile prompt", () => { + useDAWStore.getState().markInputProfileOnboardingSeen(); + expect(useDAWStore.getState().inputProfileOnboardingSeen).toBe(true); + expect(JSON.parse(storage.getItem(SETTINGS_KEY) ?? "{}").onboardingSeen).toBe(true); + }); + + it("keeps keyboard, mouse, and onboarding state atomic when storage is unavailable", () => { + const showToast = vi.fn(); + useDAWStore.setState({ showToast }); + vi.stubGlobal("localStorage", undefined); + + useDAWStore.getState().setKeyboardShortcutProfile("reaper"); + useDAWStore.getState().setMouseBehaviorProfile("logic_pro"); + useDAWStore.getState().markInputProfileOnboardingSeen(); + + expect(useDAWStore.getState()).toMatchObject({ + keyboardShortcutProfileId: "openstudio", + mouseBehaviorProfileId: "openstudio", + inputProfileOnboardingSeen: false, + }); + expect(showToast).toHaveBeenNthCalledWith( + 1, + "The keyboard profile could not be saved to local storage.", + "error", + ); + expect(showToast).toHaveBeenNthCalledWith( + 2, + "The mouse profile could not be saved to local storage.", + "error", + ); + expect(showToast).toHaveBeenNthCalledWith( + 3, + "Input profile setup could not be saved to local storage.", + "error", + ); + }); + + it("keeps keyboard, mouse, and onboarding state atomic when storage throws", () => { + const showToast = vi.fn(); + useDAWStore.setState({ showToast }); + vi.spyOn(storage, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + + useDAWStore.getState().setKeyboardShortcutProfile("cubase"); + useDAWStore.getState().setMouseBehaviorProfile("studio_one"); + useDAWStore.getState().markInputProfileOnboardingSeen(); + + expect(useDAWStore.getState()).toMatchObject({ + keyboardShortcutProfileId: "openstudio", + mouseBehaviorProfileId: "openstudio", + inputProfileOnboardingSeen: false, + }); + expect(showToast).toHaveBeenCalledTimes(3); + expect(storage.getItem(SETTINGS_KEY)).toBeNull(); + }); + + it("rejects invalid profile IDs received from untrusted persisted/UI data", () => { + useDAWStore.getState().setKeyboardShortcutProfile("deleted-profile" as never); + useDAWStore.getState().setMouseBehaviorProfile("deleted-profile" as never); + expect(useDAWStore.getState().keyboardShortcutProfileId).toBe("openstudio"); + expect(useDAWStore.getState().mouseBehaviorProfileId).toBe("openstudio"); + }); + + it("persists validated mouse overrides and resets them durably", () => { + useDAWStore.getState().setMouseModifier("clip_drag", "ctrl", "copy"); + useDAWStore.getState().setMouseModifier("timeline_click", "alt", "razor"); + + expect(useDAWStore.getState().mouseModifiers).toEqual({ + clip_drag: { primary: "copy" }, + timeline_click: { alt: "razor" }, + }); + const persisted = JSON.parse( + storage.getItem(MOUSE_MODIFIER_OVERRIDES_STORAGE_KEY) ?? "null", + ); + expect(parsePersistedMouseModifierOverrides(persisted)?.overrides) + .toEqual(useDAWStore.getState().mouseModifiers); + expect(loadStoredMouseModifierOverrides(storage)) + .toEqual(useDAWStore.getState().mouseModifiers); + + useDAWStore.getState().resetMouseModifiers(); + expect(useDAWStore.getState().mouseModifiers).toEqual({}); + expect(loadStoredMouseModifierOverrides(storage)).toEqual({}); + }); + + it("rejects malformed mouse overrides and keeps state atomic on storage failure", () => { + useDAWStore.getState().setMouseModifier("clip_drag", "primary", "copy"); + const overridesBefore = useDAWStore.getState().mouseModifiers; + const persistedBefore = storage.getItem(MOUSE_MODIFIER_OVERRIDES_STORAGE_KEY); + + useDAWStore.getState().setMouseModifier("clip_drag", "primary", "not-an-action"); + expect(useDAWStore.getState().mouseModifiers).toBe(overridesBefore); + + vi.spyOn(storage, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + useDAWStore.getState().setMouseModifier("clip_drag", "shift", "constrain"); + useDAWStore.getState().resetMouseModifiers(); + + expect(useDAWStore.getState().mouseModifiers).toBe(overridesBefore); + expect(storage.getItem(MOUSE_MODIFIER_OVERRIDES_STORAGE_KEY)).toBe(persistedBefore); + expect(loadStoredMouseModifierOverrides({ + getItem: () => JSON.stringify({ + schemaVersion: 1, + overrides: { clip_drag: { primary: "delete" } }, + }), + })).toEqual({}); + }); +}); diff --git a/frontend/src/__tests__/inputProfileWindowSync.test.ts b/frontend/src/__tests__/inputProfileWindowSync.test.ts new file mode 100644 index 0000000..4291566 --- /dev/null +++ b/frontend/src/__tests__/inputProfileWindowSync.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { useDAWStore } from "../store/useDAWStore"; +import type { CustomKeyboardShortcutProfile } from "../utils/customShortcutProfiles"; +import { + applyMixerUISnapshot, + extractMixerUISnapshot, +} from "../utils/mixerWindowSync"; +import { + hydrateInputProfilesFromNative, + startDetachedInputProfileSync, +} from "../utils/inputProfileWindowSync"; + +const originalState = useDAWStore.getState(); + +function customProfile( + id: string, + baseProfileId: "reaper" | "cubase" = "reaper", +): CustomKeyboardShortcutProfile { + return { + id, + name: "Detached profile", + baseProfileId, + bindings: { + "transport.playPause": { common: ["Code:KeyP"] }, + }, + createdAt: 10, + updatedAt: 20, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + useDAWStore.setState(originalState); +}); + +describe("detached-window input profile synchronization", () => { + it("carries built-in and active custom profile state in the retained mixer snapshot", () => { + const profile = customProfile("custom-detached"); + useDAWStore.setState({ + keyboardShortcutProfileId: "reaper", + mouseBehaviorProfileId: "ableton_live", + customKeyboardProfiles: [profile], + activeCustomKeyboardProfileId: profile.id, + customShortcuts: profile.bindings, + mouseModifiers: { clip_drag: { primary: "copy" } }, + }); + const snapshot = extractMixerUISnapshot(); + + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + mouseBehaviorProfileId: "openstudio", + customKeyboardProfiles: [], + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + mouseModifiers: {}, + }); + applyMixerUISnapshot(snapshot); + + expect(useDAWStore.getState()).toMatchObject({ + keyboardShortcutProfileId: "reaper", + mouseBehaviorProfileId: "ableton_live", + activeCustomKeyboardProfileId: "custom-detached", + customShortcuts: profile.bindings, + mouseModifiers: { clip_drag: { primary: "copy" } }, + }); + }); + + it("hydrates a reopened MIDI/plugin window from native retained state without localStorage", async () => { + const profile = customProfile("custom-reopen", "cubase"); + const retained = { + ...extractMixerUISnapshot(), + keyboardShortcutProfileId: "cubase" as const, + mouseBehaviorProfileId: "logic_pro" as const, + customKeyboardProfiles: [profile], + activeCustomKeyboardProfileId: profile.id, + customShortcuts: profile.bindings, + mouseModifiers: { timeline_click: { alt: "razor" } }, + }; + vi.spyOn(nativeBridge, "getMixerUISnapshot").mockResolvedValue({ + originWindowId: "main", + revision: 7, + payload: retained, + }); + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + mouseBehaviorProfileId: "openstudio", + customKeyboardProfiles: [], + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + mouseModifiers: {}, + }); + + await expect(hydrateInputProfilesFromNative()).resolves.toBe(true); + expect(useDAWStore.getState()).toMatchObject({ + keyboardShortcutProfileId: "cubase", + mouseBehaviorProfileId: "logic_pro", + activeCustomKeyboardProfileId: "custom-reopen", + customShortcuts: profile.bindings, + mouseModifiers: { timeline_click: { alt: "razor" } }, + }); + }); + + it("applies live main-window changes and rejects a slower stale retained read", async () => { + const listenerRef: { current?: (value: unknown) => void } = {}; + const unsubscribe = vi.fn(); + vi.spyOn(nativeBridge, "subscribe").mockImplementation((eventId, callback) => { + expect(eventId).toBe("mixerUISync"); + listenerRef.current = callback; + return unsubscribe; + }); + + const retainedResolverRef: { current?: (value: unknown) => void } = {}; + vi.spyOn(nativeBridge, "getMixerUISnapshot").mockImplementation(() => ( + new Promise((resolve) => { retainedResolverRef.current = resolve; }) + )); + + const stop = startDetachedInputProfileSync(); + const live = { + ...extractMixerUISnapshot(), + keyboardShortcutProfileId: "pro_tools" as const, + mouseBehaviorProfileId: "cakewalk_sonar" as const, + customKeyboardProfiles: [], + activeCustomKeyboardProfileId: null, + customShortcuts: {}, + }; + listenerRef.current?.({ originWindowId: "main", revision: 9, payload: live }); + expect(useDAWStore.getState()).toMatchObject({ + keyboardShortcutProfileId: "pro_tools", + mouseBehaviorProfileId: "cakewalk_sonar", + }); + + retainedResolverRef.current?.({ + originWindowId: "main", + revision: 8, + payload: { + ...live, + keyboardShortcutProfileId: "audacity", + mouseBehaviorProfileId: "audacity", + }, + }); + await Promise.resolve(); + expect(useDAWStore.getState()).toMatchObject({ + keyboardShortcutProfileId: "pro_tools", + mouseBehaviorProfileId: "cakewalk_sonar", + }); + + stop(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it("ignores malformed remote profile and custom-binding payloads", async () => { + vi.spyOn(nativeBridge, "getMixerUISnapshot").mockResolvedValue({ + originWindowId: "main", + revision: 1, + payload: { + ...extractMixerUISnapshot(), + keyboardShortcutProfileId: "not-a-profile", + customKeyboardProfiles: [{ id: "broken" }], + }, + }); + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + mouseBehaviorProfileId: "openstudio", + }); + + await expect(hydrateInputProfilesFromNative()).resolves.toBe(false); + expect(useDAWStore.getState()).toMatchObject({ + keyboardShortcutProfileId: "openstudio", + mouseBehaviorProfileId: "openstudio", + }); + }); + + it("rejects malformed remote mouse overrides", async () => { + vi.spyOn(nativeBridge, "getMixerUISnapshot").mockResolvedValue({ + originWindowId: "main", + revision: 2, + payload: { + ...extractMixerUISnapshot(), + mouseModifiers: { clip_drag: { primary: "delete" } }, + }, + }); + const mouseModifiers = { fade_handle: { shift: "symmetric" } }; + useDAWStore.setState({ mouseModifiers }); + + await expect(hydrateInputProfilesFromNative()).resolves.toBe(false); + expect(useDAWStore.getState().mouseModifiers).toBe(mouseModifiers); + }); +}); diff --git a/frontend/src/__tests__/interactionSafetyGuards.test.ts b/frontend/src/__tests__/interactionSafetyGuards.test.ts index 867def0..9870854 100644 --- a/frontend/src/__tests__/interactionSafetyGuards.test.ts +++ b/frontend/src/__tests__/interactionSafetyGuards.test.ts @@ -1,38 +1,128 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import appSource from "../App.tsx?raw"; import mixerWindowSource from "../MixerWindowApp.tsx?raw"; import midiWindowSource from "../MidiEditorWindowApp.tsx?raw"; +import pluginWindowSource from "../PluginEditorWindowApp.tsx?raw"; import contextMenuSource from "../components/ContextMenu.tsx?raw"; import modalSource from "../components/ui/Modal/Modal.tsx?raw"; import pianoRollSource from "../components/PianoRoll.tsx?raw"; +import timelineSource from "../components/Timeline.tsx?raw"; import shortcutSource from "../utils/globalShortcutDispatcher.ts?raw"; import modalGuardSource from "../utils/modalEventGuards.ts?raw"; +import { isEditorWheelOwnedTarget } from "../utils/modalEventGuards"; +import { + isEditableShortcutTarget, + isNonTextControlShortcutTarget, + shouldPreserveEditableShortcut, + shouldPreserveNonTextControlShortcut, +} from "../utils/shortcutContext"; + +function targetMatching(selectorFragment: string): EventTarget { + return { + closest: (selector: string) => selector.includes(selectorFragment) ? {} : null, + } as unknown as EventTarget; +} describe("interaction safety guards", () => { it("lets Space stop active transport from focused inputs without stealing idle text entry", () => { const editableBranchIndex = shortcutSource.indexOf("if (payload.targetIsEditable)"); - const customShortcutsIndex = shortcutSource.indexOf("const customShortcuts"); expect(editableBranchIndex).toBeGreaterThan(-1); - expect(customShortcutsIndex).toBeGreaterThan(-1); - expect(editableBranchIndex).toBeLessThan(customShortcutsIndex); expect(shortcutSource).toContain( - "isPlainSpacebar(payload) && (state.transport.isRecording || state.transport.isPlaying)", + "matchesTransportPlay && (state.transport.isRecording || state.transport.isPlaying)", ); + expect(shortcutSource).toContain("shouldPreserveEditableShortcut("); expect(shortcutSource).toContain('publishDetachedCommand("transport.stop")'); expect(shortcutSource).toContain("else state.stop()"); expect(shortcutSource).toContain("return false;"); }); it("captures keyboard shortcuts before focused controls stop propagation", () => { - for (const source of [appSource, mixerWindowSource, midiWindowSource]) { - expect(source).toContain("target instanceof HTMLSelectElement"); + for (const source of [ + appSource, + mixerWindowSource, + midiWindowSource, + pluginWindowSource, + ]) { + expect(source).toContain("isEditableShortcutTarget(e.target)"); + expect(source).toContain("isNonTextControlShortcutTarget(e.target)"); expect(source).toContain("stopPropagation: () => e.stopPropagation()"); + expect(source).toContain("stopImmediatePropagation: () => e.stopImmediatePropagation()"); expect(source).toContain('window.addEventListener("keydown", handleKeyDown, true)'); expect(source).toContain('window.removeEventListener("keydown", handleKeyDown, true)'); } }); + it("routes shortcuts from plugin controls without stealing native control keys", () => { + expect(pluginWindowSource).not.toContain("NON_TEXT_PLUGIN_CONTROL_SELECTOR"); + expect(pluginWindowSource).toContain( + "targetIsNonTextControl: isNonTextControlShortcutTarget(e.target)", + ); + expect(pluginWindowSource).toContain("isEditableShortcutTarget(e.target)"); + expect(shortcutSource).toContain("shouldPreserveNonTextControlShortcut(payload)"); + }); + + it("classifies text, selection, and ARIA editing targets consistently", () => { + for (const selector of [ + "input[type='text']", + "input[type='number']", + "[contenteditable='true']", + "select", + "[role='combobox']", + ]) { + expect(isEditableShortcutTarget(targetMatching(selector))).toBe(true); + } + + for (const selector of [ + "button", + "input[type='range']", + "[role='slider']", + ]) { + expect(isNonTextControlShortcutTarget(targetMatching(selector))).toBe(true); + } + }); + + it("preserves native control navigation while allowing registered chords", () => { + const nativeControlKeys = [ + " ", + "Enter", + "Tab", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "ArrowDown", + "Home", + "End", + "PageUp", + "PageDown", + ]; + + for (const key of nativeControlKeys) { + expect(shouldPreserveNonTextControlShortcut({ key })).toBe(true); + expect(shouldPreserveEditableShortcut({ key })).toBe(true); + } + + expect(shouldPreserveNonTextControlShortcut({ key: "s", ctrlKey: true })).toBe(false); + expect(shouldPreserveEditableShortcut({ key: "s", ctrlKey: true })).toBe(false); + }); + + it("activates the docked MIDI session from editor chrome interactions", () => { + expect(appSource).toContain('data-shortcut-context={`piano_roll:${dockedMidiEditorSession.sessionId}`}'); + expect(appSource).toContain("onPointerDownCapture={() => activateShortcutContext({"); + expect(appSource).toContain("onContextMenuCapture={() => activateShortcutContext({"); + expect(appSource).toContain("onFocusCapture={() => activateShortcutContext({"); + }); + + it("wires audio copy-drag and modified click through the semantic gesture path", () => { + expect(timelineSource).toContain( + "isTimelineClipCopyAction(modifierAction)", + ); + expect(timelineSource).toContain("shouldStartTimelineCopyDrag(true, dragType, clipEditLocked)"); + expect(timelineSource).toContain("if (!copyOnDrag && !isAlreadyInMultiSelection)"); + expect(timelineSource).toContain("selectClip(clip.id, { ctrl: true });"); + expect(timelineSource).toContain("previewStartTime: clip.startTime"); + }); + it("blocks workspace context menus while modal layers are open", () => { expect(modalGuardSource).toContain("installModalContextMenuLeakGuard"); expect(modalGuardSource).toContain('window.addEventListener("contextmenu", handleContextMenu, true)'); @@ -57,6 +147,57 @@ describe("interaction safety guards", () => { expect(modalGuardSource).toContain("event.preventDefault();"); }); + it.each([ + ["native input, including range", "input"], + ["select", "select"], + ["textarea", "textarea"], + ["button or nested button content", "button"], + ["ARIA parameter slider", "[role='slider']"], + ["modal content", "[data-modal-root='true']"], + ["modal panel", "[data-modal-panel='true']"], + ])("yields editor wheel propagation to %s", (_label, selector) => { + const event = { + target: targetMatching(selector), + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const resolveParentGesture = vi.fn(); + const handleParentWheel = () => { + if (isEditorWheelOwnedTarget(event.target)) return; + resolveParentGesture(); + event.preventDefault(); + event.stopPropagation(); + }; + + handleParentWheel(); + + expect(resolveParentGesture).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + }); + + it("lets Timeline and Piano Roll yield before resolving or consuming wheel input", () => { + const timelineWheel = timelineSource.slice( + timelineSource.indexOf("const handleWheel = (e: WheelEvent) =>"), + timelineSource.indexOf('container.addEventListener("wheel", handleWheel'), + ); + const pianoWheel = pianoRollSource.slice( + pianoRollSource.indexOf("const handleWheel = (event: WheelEvent) =>"), + pianoRollSource.indexOf('container.addEventListener("wheel", handleWheel'), + ); + + for (const [source, guard] of [ + [timelineWheel, "if (isEditorWheelOwnedTarget(e.target)) return;"], + [pianoWheel, "if (isEditorWheelOwnedTarget(event.target)) return;"], + ] as const) { + const guardIndex = source.indexOf(guard); + expect(guardIndex).toBeGreaterThan(-1); + expect(guardIndex).toBeLessThan(source.indexOf("resolveWheelGesture(")); + expect(guardIndex).toBeLessThan(source.indexOf("preventDefault()")); + expect(guardIndex).toBeLessThan(source.indexOf("stopPropagation()")); + } + }); + it("keeps context submenus open while crossing into the submenu", () => { expect(contextMenuSource).toContain("submenuCloseTimerRef"); expect(contextMenuSource).toContain("scheduleSubmenuClose"); diff --git a/frontend/src/__tests__/keyboardShortcutsModalAvailability.test.ts b/frontend/src/__tests__/keyboardShortcutsModalAvailability.test.ts new file mode 100644 index 0000000..d9f58ee --- /dev/null +++ b/frontend/src/__tests__/keyboardShortcutsModalAvailability.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { shallow } from "zustand/shallow"; +import type { ActionDef } from "../store/actionRegistry"; +import { useDAWStore } from "../store/useDAWStore"; +import { + executeShortcutActionFromModal, + getShortcutActionAvailability, + selectShortcutAvailabilityInputs, +} from "../components/KeyboardShortcutsModal"; +import keyboardShortcutsModalSource from "../components/KeyboardShortcutsModal.tsx?raw"; + +const originalTransport = useDAWStore.getState().transport; +const originalStepInputEnabled = useDAWStore.getState().stepInputEnabled; + +function action(overrides: Partial = {}): ActionDef { + return { + id: "test.modal-action", + name: "Modal action", + category: "Test", + execute: vi.fn(), + ...overrides, + }; +} + +afterEach(() => { + useDAWStore.setState({ + transport: originalTransport, + stepInputEnabled: originalStepInputEnabled, + }); + vi.restoreAllMocks(); +}); + +describe("KeyboardShortcutsModal action availability", () => { + it("projects every store input used by shortcut availability guards", () => { + const base = useDAWStore.getState(); + type StoreSnapshot = typeof base; + const withPatch = (patch: Partial): StoreSnapshot => ({ + ...base, + ...patch, + }); + const alternateEditRange = { + startTime: 0, + endTime: 1, + minNote: 48, + maxNote: 72, + includeCC: false, + }; + const cases: Array<[string, StoreSnapshot]> = [ + ["tracks", withPatch({ tracks: [...base.tracks] })], + ["trackGroups", withPatch({ trackGroups: [...base.trackGroups] })], + ["selectedTrackId", withPatch({ selectedTrackId: base.selectedTrackId ? null : "projection-track" })], + ["selectedTrackIds", withPatch({ selectedTrackIds: [...base.selectedTrackIds] })], + ["selectedClipId", withPatch({ selectedClipId: base.selectedClipId ? null : "projection-clip" })], + ["selectedClipIds", withPatch({ selectedClipIds: [...base.selectedClipIds] })], + ["selectedNoteIds", withPatch({ selectedNoteIds: [...base.selectedNoteIds] })], + ["selectedRegionIds", withPatch({ selectedRegionIds: [...base.selectedRegionIds] })], + ["selectedAutomationTarget", withPatch({ + selectedAutomationTarget: base.selectedAutomationTarget + ? null + : { kind: "master", laneId: "projection-lane", pointId: null }, + })], + ["razorEdits", withPatch({ razorEdits: [...base.razorEdits] })], + ["midiEditRange", withPatch({ + midiEditRange: base.midiEditRange ? null : alternateEditRange, + })], + ["pianoRollEditCursorTime", withPatch({ + pianoRollEditCursorTime: base.pianoRollEditCursorTime === null + ? 1 + : base.pianoRollEditCursorTime + 1, + })], + ["midiEditorSessions", withPatch({ midiEditorSessions: [...base.midiEditorSessions] })], + ["timeSelection", withPatch({ + timeSelection: base.timeSelection ? null : { start: 0, end: 1 }, + })], + ["clipboard", withPatch({ clipboard: { ...base.clipboard } })], + ["markers", withPatch({ markers: [...base.markers] })], + ["regions", withPatch({ regions: [...base.regions] })], + ["transport.currentTime", withPatch({ + transport: { ...base.transport, currentTime: base.transport.currentTime + 1 }, + })], + ["transport.tempo", withPatch({ + transport: { ...base.transport, tempo: base.transport.tempo + 1 }, + })], + ["transport.isPlaying", withPatch({ + transport: { ...base.transport, isPlaying: !base.transport.isPlaying }, + })], + ["transport.isRecording", withPatch({ + transport: { ...base.transport, isRecording: !base.transport.isRecording }, + })], + ["recordSession", withPatch({ + recordSession: base.recordSession + ? null + : { id: "projection-session", startTime: 0, trackIds: [] }, + })], + ["recordingClips", withPatch({ recordingClips: [...base.recordingClips] })], + ["stepInputEnabled", withPatch({ stepInputEnabled: !base.stepInputEnabled })], + ["canUndo", withPatch({ canUndo: !base.canUndo })], + ["canRedo", withPatch({ canRedo: !base.canRedo })], + ["globalLocked", withPatch({ globalLocked: !base.globalLocked })], + ["lockSettings", withPatch({ lockSettings: { ...base.lockSettings } })], + ["timeSignature", withPatch({ timeSignature: { ...base.timeSignature } })], + ["gridSize", withPatch({ gridSize: base.gridSize === "1/4" ? "1/8" : "1/4" })], + ["pixelsPerSecond", withPatch({ pixelsPerSecond: base.pixelsPerSecond + 1 })], + ["quantizePresets", withPatch({ quantizePresets: [...base.quantizePresets] })], + ["quantizePresetId", withPatch({ quantizePresetId: `${base.quantizePresetId}-projection` })], + ["masterAutomationLanes", withPatch({ masterAutomationLanes: [...base.masterAutomationLanes] })], + ["masterAutomationReadEnabled", withPatch({ + masterAutomationReadEnabled: !base.masterAutomationReadEnabled, + })], + ["masterAutomationWriteEnabled", withPatch({ + masterAutomationWriteEnabled: !base.masterAutomationWriteEnabled, + })], + ["suspendedMasterAutomationState", withPatch({ + suspendedMasterAutomationState: base.suspendedMasterAutomationState + ? null + : { showAutomation: false, lanes: {} }, + })], + ["mixerSnapshots", withPatch({ mixerSnapshots: [...base.mixerSnapshots] })], + ["detachedPanels", withPatch({ detachedPanels: [...base.detachedPanels] })], + ["recentProjects", withPatch({ recentProjects: [...base.recentProjects] })], + ["projectTemplates", withPatch({ projectTemplates: [...base.projectTemplates] })], + ["customToolbars", withPatch({ customToolbars: [...base.customToolbars] })], + ["trackTemplates", withPatch({ trackTemplates: [...base.trackTemplates] })], + ["activeMidiEditorSessionId", withPatch({ + activeMidiEditorSessionId: base.activeMidiEditorSessionId ? null : "projection-editor", + })], + ["pianoRollTrackId", withPatch({ pianoRollTrackId: base.pianoRollTrackId ? null : "projection-track" })], + ["pianoRollClipId", withPatch({ pianoRollClipId: base.pianoRollClipId ? null : "projection-clip" })], + ["showPianoRoll", withPatch({ showPianoRoll: !base.showPianoRoll })], + ["showPitchEditor", withPatch({ showPitchEditor: !base.showPitchEditor })], + ["pitchEditorTrackId", withPatch({ pitchEditorTrackId: base.pitchEditorTrackId ? null : "projection-track" })], + ["pitchEditorClipId", withPatch({ pitchEditorClipId: base.pitchEditorClipId ? null : "projection-clip" })], + ["trackHeight", withPatch({ trackHeight: base.trackHeight + 1 })], + ["tcpWidth", withPatch({ tcpWidth: base.tcpWidth + 1 })], + ]; + + const baseline = selectShortcutAvailabilityInputs(base); + for (const [dependency, state] of cases) { + expect( + shallow(baseline, selectShortcutAvailabilityInputs(state)), + `${dependency} must invalidate the availability projection`, + ).toBe(false); + } + }); + + it("exposes the unavailable state and reason to assistive technology", () => { + expect(keyboardShortcutsModalSource).toContain("aria-disabled={!availability.available || undefined}"); + expect(keyboardShortcutsModalSource).toContain("aria-describedby={!availability.available ? unavailableReasonId : undefined}"); + expect(keyboardShortcutsModalSource).toMatch(/role="status"\s+aria-live="polite"/); + expect(keyboardShortcutsModalSource).toContain("{availability.reason}"); + + const actionButtonStart = keyboardShortcutsModalSource.indexOf("aria-describedby={!availability.available ? unavailableReasonId : undefined}"); + const actionButtonEnd = keyboardShortcutsModalSource.indexOf("", actionButtonStart); + const reasonNode = keyboardShortcutsModalSource.indexOf("id={unavailableReasonId}", actionButtonStart); + expect(actionButtonStart).toBeGreaterThan(-1); + expect(actionButtonEnd).toBeGreaterThan(actionButtonStart); + expect(reasonNode).toBeGreaterThan(actionButtonEnd); + }); + + it("does not execute or close for a failed canHandleShortcut guard", () => { + const execute = vi.fn(); + const onClose = vi.fn(); + const result = executeShortcutActionFromModal(action({ + execute, + canHandleShortcut: () => false, + }), onClose); + + expect(result).toEqual({ + available: false, + reason: "Unavailable in the current context", + }); + expect(execute).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("does not execute or close when shortcutWhen is inactive", () => { + useDAWStore.setState((state) => ({ + transport: { ...state.transport, isPlaying: false, isRecording: false }, + })); + const execute = vi.fn(); + const onClose = vi.fn(); + const guarded = action({ execute, shortcutWhen: "transport_running" }); + + expect(getShortcutActionAvailability(guarded)).toEqual({ + available: false, + reason: "Available while transport is running", + }); + expect(executeShortcutActionFromModal(guarded, onClose).available).toBe(false); + expect(execute).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("executes and closes only when condition and contextual guard both pass", () => { + useDAWStore.setState((state) => ({ + transport: { ...state.transport, isPlaying: true, isRecording: false }, + })); + const execute = vi.fn(); + const onClose = vi.fn(); + + expect(executeShortcutActionFromModal(action({ + execute, + shortcutWhen: "transport_running", + canHandleShortcut: () => true, + }), onClose)).toEqual({ available: true }); + expect(execute).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/__tests__/keyboardShortcutsPrintSafety.test.ts b/frontend/src/__tests__/keyboardShortcutsPrintSafety.test.ts new file mode 100644 index 0000000..2d556a0 --- /dev/null +++ b/frontend/src/__tests__/keyboardShortcutsPrintSafety.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { escapePrintHtml } from "../components/KeyboardShortcutsModal"; +import keyboardShortcutsSource from "../components/KeyboardShortcutsModal.tsx?raw"; + +describe("keyboard shortcut print safety", () => { + it("escapes imported shortcut labels before inserting printable markup", () => { + expect(escapePrintHtml(` &`)).toBe( + "<img src=x onerror="alert('x')"> &", + ); + expect(keyboardShortcutsSource).toContain("escapePrintHtml(item.shortcut)"); + expect(keyboardShortcutsSource).toContain("escapePrintHtml(item.name)"); + expect(keyboardShortcutsSource).toContain("printWindow.opener = null"); + }); +}); diff --git a/frontend/src/__tests__/markerRegionActionSemantics.test.ts b/frontend/src/__tests__/markerRegionActionSemantics.test.ts new file mode 100644 index 0000000..50f8816 --- /dev/null +++ b/frontend/src/__tests__/markerRegionActionSemantics.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { useDAWStore } from "../store/useDAWStore"; +import { dispatchGlobalShortcut } from "../utils/globalShortcutDispatcher"; +import { activateShortcutContext, resetShortcutContextForTests } from "../utils/shortcutContext"; + +const originalState = useDAWStore.getState(); + +beforeEach(() => { + commandManager.clear(); + resetShortcutContextForTests(); + activateShortcutContext({ kind: "application" }); + useDAWStore.setState({ + markers: [], + regions: [], + timeSelection: null, + globalLocked: false, + lockSettings: { ...originalState.lockSettings, markers: false }, + transport: { ...originalState.transport, currentTime: 3.5 }, + keyboardShortcutProfileId: "openstudio", + customShortcuts: {}, + canUndo: false, + canRedo: false, + isModified: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + commandManager.clear(); + resetShortcutContextForTests(); + useDAWStore.setState(originalState); +}); + +describe("marker and region command semantics", () => { + it("adds a marker from the hotkey as one undoable command with a stable id", () => { + const action = getRegisteredAction("insert.marker")!; + expect(action.canHandleShortcut?.()).toBe(true); + expect(dispatchGlobalShortcut({ key: "m", code: "KeyM", source: "browser" }, "windows")) + .toBe(true); + + const marker = useDAWStore.getState().markers[0]; + expect(marker).toMatchObject({ time: 3.5, name: "Marker 1" }); + expect(useDAWStore.getState().isModified).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().markers).toEqual([]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().markers).toEqual([marker]); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("adds only a non-empty named marker and a normalized non-empty region", () => { + const prompt = vi.fn() + .mockReturnValueOnce(" ") + .mockReturnValueOnce(" Chorus "); + vi.stubGlobal("prompt", prompt); + const named = getRegisteredAction("insert.markerNamed")!; + named.execute(); + expect(useDAWStore.getState().markers).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(0); + named.execute(); + expect(useDAWStore.getState().markers[0]).toMatchObject({ name: "Chorus", time: 3.5 }); + + useDAWStore.setState({ timeSelection: { start: 8, end: 2 } }); + const region = getRegisteredAction("insert.regionFromSelection")!; + expect(region.canHandleShortcut?.()).toBe(true); + region.execute(); + expect(useDAWStore.getState().regions[0]).toMatchObject({ + name: "Region 1", + startTime: 2, + endTime: 8, + }); + expect(commandManager.getUndoStack()).toHaveLength(2); + }); + + it("updates and removes marker/region data with one reversible command each", () => { + useDAWStore.getState().addMarker(1, "Verse"); + useDAWStore.getState().addRegion(2, 6, "Body"); + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false }); + const marker = useDAWStore.getState().markers[0]; + const region = useDAWStore.getState().regions[0]; + + useDAWStore.getState().updateMarker(marker.id, { name: "Intro", time: -2, id: "spoofed" }); + expect(useDAWStore.getState().markers[0]).toMatchObject({ id: marker.id, name: "Intro", time: 0 }); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().markers[0]).toEqual(marker); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().markers[0].id).toBe(marker.id); + + useDAWStore.getState().updateRegion(region.id, { startTime: 9, endTime: 3, id: "spoofed" }); + expect(useDAWStore.getState().regions[0]).toMatchObject({ id: region.id, startTime: 3, endTime: 9 }); + useDAWStore.getState().removeMarker(marker.id); + useDAWStore.getState().removeRegion(region.id); + expect(commandManager.getUndoStack()).toHaveLength(4); + expect(useDAWStore.getState()).toMatchObject({ markers: [], regions: [] }); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().regions[0].id).toBe(region.id); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().markers[0].id).toBe(marker.id); + }); + + it("rejects invalid/no-op mutations and leaves insert chords unavailable while marker editing is locked", () => { + useDAWStore.getState().addMarker(1, "One"); + useDAWStore.getState().addRegion(2, 4, "Range"); + const marker = useDAWStore.getState().markers[0]; + const region = useDAWStore.getState().regions[0]; + commandManager.clear(); + + useDAWStore.getState().updateMarker(marker.id, { name: marker.name }); + useDAWStore.getState().updateMarker(marker.id, { time: Number.NaN }); + useDAWStore.getState().updateRegion(region.id, { startTime: region.startTime }); + useDAWStore.getState().updateRegion(region.id, { endTime: Number.POSITIVE_INFINITY }); + useDAWStore.getState().removeMarker("missing"); + useDAWStore.getState().removeRegion("missing"); + useDAWStore.getState().addMarker(Number.NaN); + useDAWStore.getState().addRegion(2, 2); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, markers: true }, + timeSelection: { start: 2, end: 4 }, + })); + expect(getRegisteredAction("insert.marker")?.canHandleShortcut?.()).toBe(false); + expect(getRegisteredAction("insert.markerNamed")?.canHandleShortcut?.()).toBe(false); + expect(getRegisteredAction("insert.regionFromSelection")?.canHandleShortcut?.()).toBe(false); + expect(dispatchGlobalShortcut({ key: "m", code: "KeyM", source: "browser" }, "windows")) + .toBe(false); + expect(useDAWStore.getState().markers).toEqual([marker]); + getRegisteredAction("insert.regionFromSelection")?.execute(); + useDAWStore.getState().removeMarker(marker.id); + useDAWStore.getState().removeRegion(region.id); + expect(useDAWStore.getState().markers).toEqual([marker]); + expect(useDAWStore.getState().regions).toEqual([region]); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, markers: false }, + globalLocked: true, + })); + expect(getRegisteredAction("insert.marker")?.canHandleShortcut?.()).toBe(false); + expect(getRegisteredAction("insert.regionFromSelection")?.canHandleShortcut?.()).toBe(false); + expect(dispatchGlobalShortcut({ key: "m", code: "KeyM", source: "browser" }, "windows")) + .toBe(false); + getRegisteredAction("insert.marker")?.execute(); + expect(useDAWStore.getState().markers).toEqual([marker]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/masterAutomationPointUndo.test.ts b/frontend/src/__tests__/masterAutomationPointUndo.test.ts new file mode 100644 index 0000000..b2bca1a --- /dev/null +++ b/frontend/src/__tests__/masterAutomationPointUndo.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { + type AutomationLane, + type AutomationPoint, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function masterLane(points: AutomationPoint[] = [], overrides: Partial = {}): AutomationLane { + return { + id: "master-volume", + param: "volume", + points, + visible: true, + mode: "read", + armed: false, + readEnabled: true, + ...overrides, + }; +} + +function currentPoints(): AutomationPoint[] { + return useDAWStore.getState().masterAutomationLanes[0]?.points ?? []; +} + +function expectCurrentPointValues(expected: Array<{ time: number; value: number }>) { + const points = currentPoints(); + expect(points.map(({ time, value }) => ({ time, value }))).toEqual(expected); + expect(points.every((point) => typeof point.id === "string" && point.id.length > 0)).toBe(true); + expect(new Set(points.map((point) => point.id)).size).toBe(expected.length); +} + +beforeEach(() => { + commandManager.clear(); + vi.spyOn(nativeBridge, "setAutomationPoints").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setAutomationMode").mockResolvedValue(true); + useDAWStore.setState({ + masterAutomationLanes: [], + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + automationWriteBehavior: "touch", + globalLocked: false, + lockSettings: { ...useDAWStore.getState().lockSettings, envelopes: false }, + canUndo: false, + canRedo: false, + isModified: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("undo-aware master automation points", () => { + it("adds a clamped point, enables read, and restores the exact prior state", () => { + const originalPoints = [ + { id: "stacked-a", time: 1, value: 0.8 }, + { id: "stacked-b", time: 1, value: 0.2 }, + ]; + useDAWStore.setState({ + masterAutomationLanes: [masterLane(originalPoints, { mode: "off", readEnabled: false })], + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + }); + + useDAWStore.getState().addMasterAutomationPoint("master-volume", -4, 2); + + expectCurrentPointValues([ + { time: 0, value: 1 }, + { time: 1, value: 0.8 }, + { time: 1, value: 0.2 }, + ]); + expect(useDAWStore.getState()).toMatchObject({ + masterAutomationReadEnabled: true, + masterAutomationEnabled: true, + canUndo: true, + }); + expect(useDAWStore.getState().masterAutomationLanes[0]).toMatchObject({ + mode: "read", + readEnabled: true, + }); + + useDAWStore.getState().undo(); + expect(currentPoints()).toEqual(originalPoints); + expect(useDAWStore.getState()).toMatchObject({ + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + }); + expect(useDAWStore.getState().masterAutomationLanes[0]).toMatchObject({ + mode: "off", + readEnabled: false, + }); + + useDAWStore.getState().redo(); + expectCurrentPointValues([ + { time: 0, value: 1 }, + { time: 1, value: 0.8 }, + { time: 1, value: 0.2 }, + ]); + }); + + it("removes exactly the requested stacked point and restores its order on undo", () => { + const originalPoints = [ + { id: "stacked-a", time: 1, value: 0.1 }, + { id: "stacked-b", time: 1, value: 0.2 }, + { id: "later", time: 2, value: 0.3 }, + ]; + useDAWStore.setState({ masterAutomationLanes: [masterLane(originalPoints)] }); + + useDAWStore.getState().removeMasterAutomationPoint("master-volume", 1); + expectCurrentPointValues([ + { time: 1, value: 0.1 }, + { time: 2, value: 0.3 }, + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(currentPoints()).toEqual(originalPoints); + useDAWStore.getState().redo(); + expectCurrentPointValues([ + { time: 1, value: 0.1 }, + { time: 2, value: 0.3 }, + ]); + }); + + it("moves a point through neighbours in one command and restores index identity", () => { + const originalPoints = [ + { id: "moving", time: 1, value: 0.1 }, + { id: "middle", time: 2, value: 0.2 }, + { id: "last", time: 3, value: 0.3 }, + ]; + useDAWStore.setState({ masterAutomationLanes: [masterLane(originalPoints)] }); + + useDAWStore.getState().moveMasterAutomationPoint("master-volume", 0, 4, 0.9); + expectCurrentPointValues([ + { time: 2, value: 0.2 }, + { time: 3, value: 0.3 }, + { time: 4, value: 0.9 }, + ]); + expect(currentPoints().map((point) => point.id)).toEqual(["middle", "last", "moving"]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(currentPoints()).toEqual(originalPoints); + useDAWStore.getState().redo(); + expectCurrentPointValues([ + { time: 2, value: 0.2 }, + { time: 3, value: 0.3 }, + { time: 4, value: 0.9 }, + ]); + }); + + it("keeps equal-time point ordering stable after a move", () => { + useDAWStore.setState({ + masterAutomationLanes: [masterLane([ + { id: "first", time: 1, value: 0.1 }, + { id: "second", time: 1, value: 0.2 }, + { id: "moving", time: 3, value: 0.3 }, + ])], + }); + + useDAWStore.getState().moveMasterAutomationPoint("master-volume", 2, 1, 0.9); + expectCurrentPointValues([ + { time: 1, value: 0.1 }, + { time: 1, value: 0.2 }, + { time: 1, value: 0.9 }, + ]); + expect(currentPoints().map((point) => point.id)).toEqual(["first", "second", "moving"]); + }); + + it("sanitizes non-finite movement and does not record malformed or missing targets", () => { + useDAWStore.setState({ masterAutomationLanes: [masterLane([{ id: "stable", time: 2, value: 0.5 }])] }); + + for (const pointIndex of [-1, 0.5, 2, Number.NaN]) { + useDAWStore.getState().moveMasterAutomationPoint( + "master-volume", + pointIndex, + 4, + 0.8, + ); + useDAWStore.getState().removeMasterAutomationPoint("master-volume", pointIndex); + } + useDAWStore.getState().addMasterAutomationPoint("missing", 1, 0.5); + useDAWStore.getState().moveMasterAutomationPoint("missing", 0, 1, 0.5); + useDAWStore.getState().removeMasterAutomationPoint("missing", 0); + expect(commandManager.getUndoStack()).toHaveLength(0); + expectCurrentPointValues([{ time: 2, value: 0.5 }]); + + useDAWStore.setState({ + masterAutomationLanes: [{ + ...masterLane(), + points: undefined, + } as unknown as AutomationLane], + }); + expect(() => { + useDAWStore.getState().moveMasterAutomationPoint("master-volume", 0, 1, 0.5); + useDAWStore.getState().removeMasterAutomationPoint("master-volume", 0); + }).not.toThrow(); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.setState({ masterAutomationLanes: [masterLane([{ id: "stable", time: 2, value: 0.5 }])] }); + + useDAWStore.getState().moveMasterAutomationPoint( + "master-volume", + 0, + Number.NaN, + Number.POSITIVE_INFINITY, + ); + expectCurrentPointValues([{ time: 2, value: 0.5 }]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("does not create an undo entry for a no-op move", () => { + useDAWStore.setState({ masterAutomationLanes: [masterLane([{ id: "stable", time: 2, value: 0.5 }])] }); + + useDAWStore.getState().moveMasterAutomationPoint("master-volume", 0, 2, 0.5); + + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().canUndo).toBe(false); + }); + + it("synchronizes execute, undo, and redo to the master backend lane", () => { + useDAWStore.setState({ masterAutomationLanes: [masterLane([{ id: "stable", time: 2, value: 0.5 }])] }); + const pointsSpy = vi.mocked(nativeBridge.setAutomationPoints); + + useDAWStore.getState().moveMasterAutomationPoint("master-volume", 0, 3, 0.75); + useDAWStore.getState().undo(); + useDAWStore.getState().redo(); + + expect(pointsSpy).toHaveBeenCalledTimes(3); + expect(pointsSpy.mock.calls.every(([trackId, param]) => ( + trackId === "master" && param === "volume" + ))).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/masterFXRemovalContract.test.ts b/frontend/src/__tests__/masterFXRemovalContract.test.ts new file mode 100644 index 0000000..71622cd --- /dev/null +++ b/frontend/src/__tests__/masterFXRemovalContract.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import audioEngineHeaderSource from "../../../Source/AudioEngine.h?raw"; +import audioEngineSource from "../../../Source/AudioEngine.cpp?raw"; +import mainComponentSource from "../../../Source/MainComponent.cpp?raw"; +import nativeBridgeSource from "../services/NativeBridge.ts?raw"; +import automationActionsSource from "../store/actions/automation.ts?raw"; +import fxChainPanelSource from "../components/FXChainPanel.tsx?raw"; + +describe("master FX removal contracts", () => { + it("publishes a state-synchronized master-stage reorder through the C++ bridge", () => { + expect(audioEngineHeaderSource).toContain("bool reorderMasterFX(int fromIndex, int toIndex);"); + const functionStart = audioEngineSource.indexOf( + "bool AudioEngine::reorderMasterFX(int fromIndex, int toIndex)", + ); + const nextFunction = audioEngineSource.indexOf( + "void AudioEngine::openMasterFXEditor", + functionStart, + ); + expect(functionStart).toBeGreaterThan(-1); + expect(nextFunction).toBeGreaterThan(functionStart); + const reorderBody = audioEngineSource.slice(functionStart, nextFunction); + expect(reorderBody).toContain("specCopy = desiredMasterStageSpec;"); + expect(reorderBody).toContain("syncStageSpecStateFromActive(specCopy, activeStage);"); + expect(reorderBody).toContain("specCopy.slots.erase"); + expect(reorderBody).toContain("specCopy.slots.insert"); + expect(reorderBody).toContain("publishMasterStageSpec(specCopy)"); + + expect(mainComponentSource).toContain('.withNativeFunction ("reorderMasterFX"'); + expect(mainComponentSource).toContain("completion(audioEngine.reorderMasterFX((int)args[0], (int)args[1]))"); + }); + + it("keeps the TypeScript bridge and undo restore contract complete", () => { + expect(nativeBridgeSource).toContain("reorderMasterFX?: (fromIndex: number, toIndex: number) => Promise;"); + expect(nativeBridgeSource).toContain("async reorderMasterFX(fromIndex: number, toIndex: number): Promise"); + expect(nativeBridgeSource).toContain("window.__JUCE__.backend.reorderMasterFX(fromIndex, toIndex)"); + + expect(automationActionsSource).toContain("removeMasterFXWithUndo: async (fxIndex: number)"); + expect(automationActionsSource).toContain("getMasterPluginState(fxIndex)"); + expect(automationActionsSource).toContain("addMasterBuiltInFX(pluginReference)"); + expect(automationActionsSource).toContain("addMasterS13FX(pluginReference)"); + expect(automationActionsSource).toContain("addMasterFX(pluginReference)"); + expect(automationActionsSource).toContain("setMasterPluginState(appendedIndex, savedState)"); + expect(automationActionsSource).toContain("setMasterFXPrecisionOverride("); + expect(automationActionsSource).toContain("reorderMasterFX(appendedIndex, fxIndex)"); + }); + + it("routes selected master-slot removal through the undo-aware store action", () => { + expect(fxChainPanelSource).toContain("removeMasterFXWithUndo: s.removeMasterFXWithUndo"); + expect(fxChainPanelSource).toContain("success = await removeMasterFXWithUndo(fxIndex)"); + expect(fxChainPanelSource).not.toContain("await nativeBridge.removeMasterFX(fxIndex)"); + expect(fxChainPanelSource).not.toContain('if (chainType === "master") return "claimed_noop"'); + }); +}); diff --git a/frontend/src/__tests__/midiEditLockSafety.test.ts b/frontend/src/__tests__/midiEditLockSafety.test.ts new file mode 100644 index 0000000..8b4d226 --- /dev/null +++ b/frontend/src/__tests__/midiEditLockSafety.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type MIDICCEvent, + type MIDIClip, + type MIDIEvent, + useDAWStore, +} from "../store/useDAWStore"; +import { noteIdFor } from "../utils/midiNotes"; + +const originalState = useDAWStore.getState(); +const TRACK_ID = "midi-lock-track"; +const CLIP_ID = "midi-lock-clip"; +const firstNoteId = noteIdFor(CLIP_ID, 0.25, 60); +const secondNoteId = noteIdFor(CLIP_ID, 1, 64); + +const originalEvents: MIDIEvent[] = [ + { type: "noteOn", timestamp: 0.25, note: 60, velocity: 90 }, + { type: "noteOff", timestamp: 0.75, note: 60, velocity: 0 }, + { type: "noteOn", timestamp: 1, note: 64, velocity: 84 }, + { type: "noteOff", timestamp: 1.5, note: 64, velocity: 0 }, +]; +const originalCCEvents: MIDICCEvent[] = [{ cc: 1, time: 0.5, value: 64 }]; + +type StoreState = ReturnType; +type MutationCase = readonly [string, (state: StoreState) => unknown]; + +function seedEditableMidiTarget(lock: "global" | "items" | "clip" | "frozen") { + const clip: MIDIClip = { + id: CLIP_ID, + name: "Lock target", + startTime: 0, + duration: 4, + sourceLength: 4, + loopLength: 4, + events: originalEvents.map((event) => ({ ...event })), + ccEvents: originalCCEvents.map((event) => ({ ...event })), + quantizeBackup: { + events: originalEvents.map((event) => ({ ...event })), + ccEvents: originalCCEvents.map((event) => ({ ...event })), + }, + color: "#4361ee", + locked: lock === "clip", + }; + const track = createDefaultTrack(TRACK_ID, "MIDI Lock", "#4361ee", "midi", []); + track.midiClips = [clip]; + track.frozen = lock === "frozen"; + + useDAWStore.setState((state) => ({ + tracks: [track], + selectedTrackId: TRACK_ID, + selectedTrackIds: [TRACK_ID], + selectedClipId: CLIP_ID, + selectedClipIds: [CLIP_ID], + selectedNoteIds: [firstNoteId, secondNoteId], + pianoRollTrackId: TRACK_ID, + pianoRollClipId: CLIP_ID, + midiEditRange: { + startTime: 0, + endTime: 2, + minNote: 0, + maxNote: 127, + includeCC: true, + }, + midiNoteClipboard: { + notes: [{ startTime: 0, noteNumber: 67, duration: 0.5, velocity: 88 }], + sourceTrackId: TRACK_ID, + sourceClipId: CLIP_ID, + isCut: false, + }, + midiRangeClipboard: { + rangeLength: 1, + notes: [{ startTime: 0, noteNumber: 67, duration: 0.5, velocity: 88 }], + ccEvents: [{ cc: 1, time: 0.25, value: 90 }], + sourceTrackId: TRACK_ID, + sourceClipId: CLIP_ID, + isCut: false, + }, + globalLocked: lock === "global", + lockSettings: { ...state.lockSettings, items: lock === "items" }, + isModified: false, + canUndo: false, + canRedo: false, + } as Partial)); +} + +function mutationSnapshot() { + const state = useDAWStore.getState(); + return JSON.stringify({ + tracks: state.tracks, + selectedNoteIds: state.selectedNoteIds, + midiNoteClipboard: state.midiNoteClipboard, + midiRangeClipboard: state.midiRangeClipboard, + isModified: state.isModified, + }); +} + +const changedEvents: MIDIEvent[] = [ + { type: "noteOn", timestamp: 0.5, note: 72, velocity: 100 }, + { type: "noteOff", timestamp: 1, note: 72, velocity: 0 }, +]; +const changedCC: MIDICCEvent[] = [{ cc: 1, time: 0.75, value: 100 }]; + +const mutationCases: readonly MutationCase[] = [ + ["preview events", (s) => s.previewMIDIClipEvents(TRACK_ID, CLIP_ID, changedEvents)], + ["commit events", (s) => s.commitMIDIClipEvents(TRACK_ID, CLIP_ID, originalEvents, changedEvents)], + ["glue notes", (s) => s.glueSelectedMIDINotes(TRACK_ID, CLIP_ID, [firstNoteId, secondNoteId])], + ["add note", (s) => s.addMIDINote(TRACK_ID, CLIP_ID, 2, 67, 0.5)], + ["remove notes", (s) => s.removeMIDINotes(TRACK_ID, CLIP_ID, [firstNoteId])], + ["move notes", (s) => s.moveMIDINotes(TRACK_ID, CLIP_ID, [firstNoteId], 0.25, 1)], + ["resize note", (s) => s.resizeMIDINote(TRACK_ID, CLIP_ID, firstNoteId, 0.5, 1)], + ["note velocity", (s) => s.updateMIDINoteVelocity(TRACK_ID, CLIP_ID, 0.25, 60, 32)], + ["preview CC", (s) => s.updateMIDICCEvents(TRACK_ID, CLIP_ID, changedCC, { transient: true })], + ["commit CC", (s) => s.commitMIDICCEvents(TRACK_ID, CLIP_ID, originalCCEvents, changedCC)], + ["cut notes", (s) => s.cutSelectedMIDINotes(TRACK_ID, CLIP_ID)], + ["cut range", (s) => s.cutMIDIRange(TRACK_ID, CLIP_ID)], + ["delete range", (s) => s.deleteMIDIRange(TRACK_ID, CLIP_ID)], + ["paste notes", (s) => s.pasteMIDINotes(TRACK_ID, CLIP_ID, 2)], + ["paste range", (s) => s.pasteMIDIRange(TRACK_ID, CLIP_ID, 2)], + ["duplicate notes", (s) => s.duplicateSelectedMIDINotes(TRACK_ID, CLIP_ID)], + ["duplicate range", (s) => s.duplicateMIDIRange(TRACK_ID, CLIP_ID)], + ["repeat selection", (s) => s.repeatMIDISelection(TRACK_ID, CLIP_ID)], + ["quantize", (s) => s.quantizeSelectedMIDINotes(TRACK_ID, CLIP_ID, 0.5)], + ["quantize last", (s) => s.quantizeSelectedMIDINotesUsingLast(TRACK_ID, CLIP_ID)], + ["reset quantize", (s) => s.resetMIDIQuantize(TRACK_ID, CLIP_ID)], + ["freeze quantize", (s) => s.freezeMIDIQuantize(TRACK_ID, CLIP_ID)], + ["humanize", (s) => s.humanizeSelectedMIDINotes(TRACK_ID, CLIP_ID)], + ["set velocity", (s) => s.setSelectedMIDINoteVelocity(TRACK_ID, CLIP_ID, 40)], + ["scale velocity", (s) => s.scaleSelectedMIDINoteVelocity(TRACK_ID, CLIP_ID, 0.5)], + ["randomize velocity", (s) => s.randomizeSelectedMIDINoteVelocity(TRACK_ID, CLIP_ID)], + ["set length", (s) => s.setSelectedMIDINoteLength(TRACK_ID, CLIP_ID, 0.25)], + ["legato", (s) => s.legatoSelectedMIDINotes(TRACK_ID, CLIP_ID)], + ["reverse selection", (s) => s.reverseSelectedMIDINotes(TRACK_ID, CLIP_ID)], + ["invert pitches", (s) => s.invertSelectedMIDINotePitches(TRACK_ID, CLIP_ID)], + ["mirror pitches", (s) => s.mirrorSelectedMIDINotePitches(TRACK_ID, CLIP_ID, 62)], + ["snap pitches", (s) => s.snapSelectedMIDINotesToScale(TRACK_ID, CLIP_ID, 0, "major")], + ["mute notes", (s) => s.toggleSelectedMIDINoteMute(TRACK_ID, CLIP_ID, true)], + ["insert chord", (s) => s.insertMIDIChord(TRACK_ID, CLIP_ID, 2, 60, "major")], + ["crop to notes", (s) => s.cropMIDIClipToSelectedNotes(TRACK_ID, CLIP_ID)], + ["transpose all", (s) => s.transposeMIDINotes(CLIP_ID, 2)], + ["scale all velocity", (s) => s.scaleMIDINoteVelocity(CLIP_ID, 0.5)], + ["reverse all", (s) => s.reverseMIDINotes(CLIP_ID)], + ["invert all", (s) => s.invertMIDINotes(CLIP_ID)], + ["note expression", (s) => s.setNoteExpression(CLIP_ID, firstNoteId, { pressure: 0.5 })], +] as const; + +beforeEach(() => { + commandManager.clear(); +}); + +afterEach(() => { + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe.each(["global", "items", "clip", "frozen"] as const)("%s MIDI edit lock", (lock) => { + it.each(mutationCases)("blocks %s without mutation or history", (_name, mutate) => { + seedEditableMidiTarget(lock); + const before = mutationSnapshot(); + + mutate(useDAWStore.getState()); + + expect(mutationSnapshot()).toBe(before); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/midiEditorTimelineRange.test.ts b/frontend/src/__tests__/midiEditorTimelineRange.test.ts index 840d158..a439f7b 100644 --- a/frontend/src/__tests__/midiEditorTimelineRange.test.ts +++ b/frontend/src/__tests__/midiEditorTimelineRange.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import pianoRollSource from "../components/PianoRoll.tsx?raw"; import timelineSource from "../components/Timeline.tsx?raw"; +import { getTimelineVisibleContentEnd } from "../utils/contextWheelBehaviors"; import { serializeMIDIClipsForBackend } from "../utils/midiClipSerialization"; describe("MIDI editor timeline range", () => { @@ -20,8 +21,11 @@ describe("MIDI editor timeline range", () => { }); it("counts MIDI clips when sizing the main timeline scroll range", () => { - expect(timelineSource).toContain("track.midiClips.forEach"); - expect(timelineSource).toContain("const clipEnd = clip.startTime + clip.duration"); + expect(timelineSource).toContain("const maxClipEnd = getTimelineVisibleContentEnd("); + expect(getTimelineVisibleContentEnd([{ + clips: [{ startTime: 2, duration: 3 }], + midiClips: [{ startTime: 20, duration: 4 }], + }])).toBe(24); }); it("preserves overlapping MIDI clips as separate scheduled clips", () => { diff --git a/frontend/src/__tests__/midiInputMeter.test.ts b/frontend/src/__tests__/midiInputMeter.test.ts new file mode 100644 index 0000000..77b27aa --- /dev/null +++ b/frontend/src/__tests__/midiInputMeter.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it } from "vitest"; +import audioEngineSource from "../../../Source/AudioEngine.cpp?raw"; +import mainComponentSource from "../../../Source/MainComponent.cpp?raw"; +import trackProcessorSource from "../../../Source/TrackProcessor.cpp?raw"; +import { getStereoMeterChannelLevels } from "../components/PeakMeter"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; +import { resolveTrackMeterPresentation } from "../utils/trackMeterPresentation"; + +const initialState = useDAWStore.getState(); + +function normalizeSourceText(source: string): string { + return source.replace(/\r\n?/g, "\n"); +} + +afterEach(() => { + useDAWStore.setState(initialState, true); +}); + +describe("raw MIDI input metering", () => { + it("uses MIDI only for armed MIDI-capable tracks with silent audio output", () => { + expect(resolveTrackMeterPresentation(0, 0.8, true, "midi")).toEqual({ + source: "midi_input", + normalizedLevel: 0.8, + }); + expect(resolveTrackMeterPresentation(0, 0.8, true, "instrument").source) + .toBe("midi_input"); + expect(resolveTrackMeterPresentation(0, 0.8, false, "midi").source) + .toBe("idle"); + expect(resolveTrackMeterPresentation(0, 0.8, true, "audio").source) + .toBe("idle"); + }); + + it("gives real post-FX audio output precedence over simultaneous MIDI input", () => { + const result = resolveTrackMeterPresentation(0.2, 1, true, "instrument"); + expect(result).toEqual({ source: "audio", normalizedLevel: 0.2 }); + }); + + it("keeps the two MIDI activity lanes exactly mirrored", () => { + expect(getStereoMeterChannelLevels(0.72, 0.72, "midi_input")).toEqual({ + leftLevel: 0.72, + rightLevel: 0.72, + leftRms: 0.72, + rightRms: 0.72, + }); + const audio = getStereoMeterChannelLevels(0.72, 0.5, "audio"); + expect(audio.leftLevel).not.toBe(audio.rightLevel); + }); + + it("stores MIDI activity separately from audio peaks and clipping", () => { + const track = createDefaultTrack("midi-meter", "MIDI Meter", "#67e8f9", "midi"); + track.armed = true; + useDAWStore.setState({ + tracks: [track], + meterLevels: {}, + midiInputLevels: {}, + peakLevels: {}, + clippingStates: {}, + }); + + useDAWStore.getState().batchUpdateMeterLevels( + { [track.id]: 0 }, + 0, + { [track.id]: false }, + false, + { [track.id]: 0.91 }, + ); + + const state = useDAWStore.getState(); + expect(state.midiInputLevels[track.id]).toBe(0.91); + expect(state.meterLevels[track.id]).toBe(0); + expect(state.peakLevels[track.id]).toBe(0); + expect(state.clippingStates[track.id]).toBe(false); + }); + + it("captures filtered armed input before monitoring and emits it in the batched meter event", () => { + const normalizedAudioEngineSource = normalizeSourceText(audioEngineSource); + const inputRouting = normalizedAudioEngineSource.slice( + normalizedAudioEngineSource.indexOf("// Route MIDI to appropriate tracks"), + ); + expect(inputRouting).toContain("if (track->getRecordArmed())\n track->registerMIDIInputActivity(message);"); + expect(inputRouting.indexOf("track->registerMIDIInputActivity(message)")).toBeLessThan( + inputRouting.indexOf("track->getInputMonitoring()"), + ); + expect(trackProcessorSource).toContain("message.isNoteOn()"); + expect(trackProcessorSource).toContain("message.isController()"); + expect(trackProcessorSource).toContain("Note-off and transport/clock/active-sensing messages"); + expect(mainComponentSource).toContain('"midiInputLevels"'); + expect(mainComponentSource).toContain("audioEngine.getMIDIInputLevels()"); + }); +}); diff --git a/frontend/src/__tests__/mixerWindowSyncSubscription.test.ts b/frontend/src/__tests__/mixerWindowSyncSubscription.test.ts new file mode 100644 index 0000000..e76edf4 --- /dev/null +++ b/frontend/src/__tests__/mixerWindowSyncSubscription.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { useDAWStore } from "../store/useDAWStore"; +import { startMixerUISync } from "../utils/mixerWindowSync"; + +const originalState = useDAWStore.getState(); +let stopSync: (() => void) | null = null; + +afterEach(() => { + stopSync?.(); + stopSync = null; + vi.restoreAllMocks(); + useDAWStore.setState(originalState); +}); + +describe("mixer window sync subscription", () => { + it("ignores unchanged playhead availability and meter hot paths while publishing durable changes", () => { + const publish = vi.spyOn(nativeBridge, "publishMixerUISnapshot").mockResolvedValue(true); + vi.spyOn(nativeBridge, "subscribe").mockReturnValue(() => {}); + useDAWStore.setState((state) => ({ + keyboardShortcutProfileId: "openstudio", + canUndo: false, + transport: { ...state.transport, currentTime: 1.25 }, + meterLevels: {}, + peakLevels: {}, + clippingStates: {}, + automatedParamValues: {}, + })); + + stopSync = startMixerUISync(); + expect(publish).toHaveBeenCalledTimes(1); + + useDAWStore.setState((state) => ({ + transport: { ...state.transport, currentTime: 1.3 }, + })); + useDAWStore.setState({ + meterLevels: { track: 0.5 }, + peakLevels: { track: 0.75 }, + clippingStates: { track: false }, + automatedParamValues: { track: { volume: -3 } }, + }); + expect(publish).toHaveBeenCalledTimes(1); + + useDAWStore.setState({ keyboardShortcutProfileId: "reaper" }); + expect(publish).toHaveBeenCalledTimes(2); + + // Detached shortcut availability remains live even when the mixer payload + // itself did not otherwise change. + useDAWStore.setState({ canUndo: true }); + expect(publish).toHaveBeenCalledTimes(3); + }); + + it("publishes when hot playhead or razor state changes detached action availability", () => { + const publish = vi.spyOn(nativeBridge, "publishMixerUISnapshot").mockResolvedValue(true); + vi.spyOn(nativeBridge, "subscribe").mockReturnValue(() => {}); + useDAWStore.setState((state) => ({ + canUndo: false, + razorEdits: [], + transport: { ...state.transport, currentTime: 0 }, + })); + + stopSync = startMixerUISync(); + expect(publish).toHaveBeenCalledTimes(1); + + // At a positive playhead position, previous-grid/boundary actions become + // available even though currentTime is intentionally not in the durable + // mixer snapshot dependency list. + useDAWStore.setState((state) => ({ + transport: { ...state.transport, currentTime: 1.25 }, + })); + expect(publish).toHaveBeenCalledTimes(2); + + useDAWStore.setState({ razorEdits: [{ trackId: "missing-track", start: 0, end: 1 }] }); + expect(publish).toHaveBeenCalledTimes(3); + }); +}); diff --git a/frontend/src/__tests__/mouseBehaviorProfiles.test.ts b/frontend/src/__tests__/mouseBehaviorProfiles.test.ts new file mode 100644 index 0000000..7b44e87 --- /dev/null +++ b/frontend/src/__tests__/mouseBehaviorProfiles.test.ts @@ -0,0 +1,609 @@ +import { describe, expect, it } from "vitest"; +import { resolveMouseModifierAction } from "../utils/mouseModifierResolver"; +import { getMouseBehaviorProfile } from "../utils/mouseBehaviorProfiles"; +import type { ShortcutPlatform } from "../utils/platform"; +import type { KeyboardShortcutProfileId } from "../utils/shortcutProfiles"; +import { + resolveWheelGesture, + type WheelEventLike, +} from "../utils/wheelGestureResolver"; + +const DAW_PROFILE_IDS = [ + "pro_tools", + "cubase", + "reaper", + "audacity", + "logic_pro", + "fl_studio", + "ableton_live", + "studio_one", + "bitwig_studio", + "reason", + "cakewalk_sonar", + "garageband", + "digital_performer", + "ardour", + "adobe_audition", + "mixcraft", + "waveform", + "renoise", +] as const satisfies readonly KeyboardShortcutProfileId[]; + +const TEST_PLATFORMS = ["macos", "windows"] as const satisfies readonly ShortcutPlatform[]; + +type ModifierSignature = `${0 | 1}${0 | 1}${0 | 1}${0 | 1}`; + +function rawModifierCombinations(): WheelEventLike[] { + return Array.from({ length: 16 }, (_, bits) => ({ + ctrlKey: Boolean(bits & 1), + metaKey: Boolean(bits & 2), + altKey: Boolean(bits & 4), + shiftKey: Boolean(bits & 8), + deltaY: 120, + })); +} + +function normalizedSignature( + event: WheelEventLike, + platform: ShortcutPlatform, +): ModifierSignature { + const primary = platform === "macos" ? event.metaKey : event.ctrlKey; + const secondary = platform === "macos" ? event.ctrlKey : event.metaKey; + return `${Number(Boolean(primary))}${Number(Boolean(secondary))}${Number(Boolean(event.altKey))}${Number(Boolean(event.shiftKey))}` as ModifierSignature; +} + +type ExpectedRules = Partial>; + +function expectedTimelineRules( + id: typeof DAW_PROFILE_IDS[number], + platform: ShortcutPlatform, +): ExpectedRules { + switch (id) { + case "pro_tools": + return { + "0000": "pro-tools.vertical-scroll", + "0001": "pro-tools.shift-horizontal-scroll", + "0010": "pro-tools.option-horizontal-zoom", + "0011": "pro-tools.option-shift-waveform-zoom", + "1000": "pro-tools.slow-vertical-scroll", + "0100": "pro-tools.fast-vertical-scroll", + }; + case "cubase": + return { + "0000": "cubase.vertical-scroll", + "0001": "cubase.shift-horizontal-scroll", + "1000": "cubase.horizontal-zoom", + "1001": "cubase.vertical-zoom", + }; + case "reaper": + return { + "0000": "reaper.horizontal-zoom", + "1000": "reaper.vertical-zoom", + "0010": "reaper.horizontal-scroll", + "1010": "reaper.vertical-scroll", + }; + case "audacity": + return { + "0000": "audacity.vertical-scroll", + "0001": "audacity.shift-horizontal-scroll", + "1000": "audacity.horizontal-zoom", + }; + case "logic_pro": + return platform === "macos" + ? { "0110": "logic-pro.control-option-horizontal-zoom" } + : { "1010": "logic-pro.control-option-horizontal-zoom" }; + case "fl_studio": + return {}; + case "ableton_live": + return { + "0000": "ableton-live.vertical-scroll", + "0001": "ableton-live.shift-horizontal-scroll", + "1000": "ableton-live.horizontal-zoom", + }; + case "studio_one": + return { + "0000": "studio-one.vertical-scroll", + "0001": "studio-one.shift-horizontal-scroll", + "1000": "studio-one.vertical-zoom", + "1001": "studio-one.horizontal-zoom", + }; + case "bitwig_studio": + return { "1010": "bitwig-studio.control-alt-horizontal-zoom" }; + case "reason": + return { + "0000": "reason.vertical-scroll", + "0001": "reason.shift-horizontal-scroll", + "1000": "reason.horizontal-zoom", + "1001": "reason.vertical-sequencer-zoom", + }; + case "cakewalk_sonar": + return { + "0010": "cakewalk-sonar.alt-horizontal-zoom", + "0011": "cakewalk-sonar.alt-shift-fast-horizontal-zoom", + "1010": "cakewalk-sonar.control-alt-track-height", + }; + case "garageband": + return {}; + case "digital_performer": + return { + "0010": "digital-performer.option-horizontal-zoom", + "0110": "digital-performer.option-control-track-height", + }; + case "ardour": + return { + "0000": "ardour.vertical-scroll", + "0001": "ardour.shift-horizontal-scroll", + "1000": "ardour.horizontal-zoom", + }; + case "adobe_audition": + // Audition's implementable default is limited to the ruler/zoom bar. + return {}; + case "mixcraft": + return { + "0000": "mixcraft.horizontal-zoom", + "1000": "mixcraft.control-horizontal-scroll", + "0001": "mixcraft.shift-vertical-scroll", + }; + case "waveform": + return { + "0000": "waveform.horizontal-zoom", + "1000": "waveform.control-vertical-zoom", + }; + case "renoise": + return {}; + } +} + +describe("DAW mouse behavior profiles", () => { + it("uses source-safe exact pointer mappings for every profile and platform", () => { + const unmodifiedActions = { + clip_drag: "move", + clip_resize: "resize", + timeline_click: "seek", + track_header: "select", + automation_point: "move", + fade_handle: "adjust", + ruler_click: "seek", + } as const; + + for (const platform of TEST_PLATFORMS) { + for (const id of DAW_PROFILE_IDS) { + const profile = getMouseBehaviorProfile(id, platform); + const clipOverrides: ExpectedRules = {}; + if (["pro_tools", "cubase", "logic_pro", "adobe_audition"].includes(id)) { + clipOverrides["0010"] = "copy"; + } else if (id === "studio_one" || id === "mixcraft") { + clipOverrides["0010"] = "copy"; + if (id === "mixcraft") clipOverrides["0011"] = "copy_preserve_time"; + } else if (id === "ableton_live" || id === "reason") { + clipOverrides[platform === "macos" ? "0010" : "1000"] = "copy"; + } else if (id === "reaper") { + clipOverrides["1000"] = "copy"; + } + const automationOverrides: ExpectedRules = {}; + if (id === "pro_tools") { + automationOverrides["0001"] = "constrain_y"; + } else if (id === "cubase") { + automationOverrides["1000"] = "constrain_axis"; + for (const signature of [ + "1100", + "1010", + "1001", + "1110", + "1101", + "1011", + "1111", + ] as const) { + automationOverrides[signature] = "constrain_axis_bypass_snap"; + } + } else if (id === "fl_studio") { + automationOverrides["0001"] = "constrain_x"; + automationOverrides["1000"] = "constrain_y"; + automationOverrides["0010"] = "bypass_snap"; + automationOverrides["0011"] = "constrain_x_bypass_snap"; + automationOverrides["1010"] = "constrain_y_bypass_snap"; + } else if (id === "ableton_live") { + automationOverrides["0001"] = "fine"; + automationOverrides[platform === "macos" ? "1000" : "0010"] = "bypass_snap"; + automationOverrides[platform === "macos" ? "1001" : "0011"] = "fine_bypass_snap"; + } else if (id === "reason") { + automationOverrides["0001"] = "constrain_axis"; + automationOverrides[platform === "macos" ? "0010" : "1000"] = "copy"; + automationOverrides[platform === "macos" ? "0011" : "1001"] = "copy_constrain_axis"; + } else if (id === "adobe_audition") { + automationOverrides["0001"] = "constrain_axis"; + } + + for (const [context, unmodified] of Object.entries(unmodifiedActions) as Array< + [keyof typeof unmodifiedActions, typeof unmodifiedActions[keyof typeof unmodifiedActions]] + >) { + for (const event of rawModifierCombinations()) { + const signature = normalizedSignature(event, platform); + const expected = signature === "0000" + ? unmodified + : context === "clip_drag" + ? clipOverrides[signature] ?? "none" + : context === "automation_point" + ? automationOverrides[signature] ?? "none" + : "none"; + expect(resolveMouseModifierAction( + event, + context, + { platform, profile: profile.modifiers }, + ), `${id}/${platform}/${context}/${signature}`).toBe(expected); + } + } + } + } + }); + + for (const platform of TEST_PLATFORMS) { + for (const id of DAW_PROFILE_IDS) { + it(`matches only documented ${id} timeline modifiers on ${platform}`, () => { + const profile = getMouseBehaviorProfile(id, platform); + const expected = expectedTimelineRules(id, platform); + + for (const event of rawModifierCombinations()) { + const signature = normalizedSignature(event, platform); + const gesture = resolveWheelGesture( + event, + { + surface: "timeline", + subtarget: "content", + platform, + deviceHint: "mouse", + hoveredTargetId: "track-1", + }, + profile.wheel, + ); + const expectedRuleId = expected[signature] ?? null; + expect(gesture.ruleId, `${id}/${platform}/${signature}`).toBe(expectedRuleId); + expect(gesture.matched, `${id}/${platform}/${signature}`).toBe(expectedRuleId !== null); + } + }); + } + } + + it("uses exact four-flag predicates for every source-DAW timeline rule", () => { + for (const platform of TEST_PLATFORMS) { + for (const id of DAW_PROFILE_IDS) { + const timelineRules = getMouseBehaviorProfile(id, platform).wheel.rules + .filter((rule) => rule.surface === "timeline"); + for (const rule of timelineRules) { + expect(rule.modifiers, `${id}/${platform}/${rule.id}`).toEqual({ + primary: expect.any(Boolean), + secondary: expect.any(Boolean), + alt: expect.any(Boolean), + shift: expect.any(Boolean), + }); + } + } + } + }); + + it("does not silently inherit OpenStudio editor/TCP wheel mutations in vendor profiles", () => { + const openStudioOnlySurfaces = new Set(["tcp", "piano_roll", "pitch_editor", "parameter"]); + for (const platform of TEST_PLATFORMS) { + for (const id of DAW_PROFILE_IDS) { + const rules = getMouseBehaviorProfile(id, platform).wheel.rules; + expect(rules.some((rule) => ( + openStudioOnlySurfaces.has(rule.surface) + && /^(tcp|piano-roll|pitch-editor|parameter)\./.test(rule.id) + )), `${id}/${platform}`).toBe(false); + expect(rules.some((rule) => rule.id === `${id}.parameter-safety-fallback`)).toBe(true); + expect(rules.some((rule) => rule.id === "browser.suppress-browser-zoom")).toBe(true); + } + } + }); + + it.each(TEST_PLATFORMS)("suppresses unsourced parameter wheel changes on %s", (platform) => { + for (const id of ["logic_pro", "garageband", "digital_performer", "waveform", "renoise"] as const) { + expect(resolveWheelGesture( + { deltaY: 120 }, + { surface: "parameter", subtarget: "control", platform }, + getMouseBehaviorProfile(id, platform).wheel, + )).toMatchObject({ + ruleId: `${id}.parameter-safety-fallback`, + operation: "suppress", + preventDefault: true, + stopPropagation: true, + }); + } + }); + + it("implements Pro Tools zoom and relative scroll-speed semantics", () => { + const profile = getMouseBehaviorProfile("pro_tools", "macos"); + const resolve = (event: WheelEventLike) => resolveWheelGesture( + { ...event, deltaY: 120 }, + { + surface: "timeline", + subtarget: "track", + platform: "macos", + hoveredTargetId: "track-1", + }, + profile.wheel, + ); + + const plain = resolve({}); + const slow = resolve({ metaKey: true }); + const fast = resolve({ ctrlKey: true }); + const optionZoom = resolve({ altKey: true }); + const waveformZoom = resolve({ altKey: true, shiftKey: true }); + + expect(slow.amount).toBeLessThan(plain.amount); + expect(fast.amount).toBeGreaterThan(plain.amount); + expect(optionZoom).toMatchObject({ operation: "zoom", target: "timeline" }); + expect(waveformZoom).toMatchObject({ + operation: "zoom", + target: "waveform-amplitude", + }); + expect(resolveWheelGesture( + { deltaY: 120, altKey: true, shiftKey: true }, + { surface: "timeline", subtarget: "ruler", platform: "macos" }, + profile.wheel, + ).matched).toBe(false); + expect(resolve({ ctrlKey: true, altKey: true }).matched).toBe(false); + }); + + it("limits FL Studio Playlist wheel commands to exact track and clip hit targets", () => { + for (const platform of TEST_PLATFORMS) { + const profile = getMouseBehaviorProfile("fl_studio", platform); + for (const event of rawModifierCombinations()) { + expect(resolveWheelGesture( + event, + { surface: "timeline", subtarget: "content", platform, deviceHint: "mouse" }, + profile.wheel, + ).matched).toBe(false); + } + expect(resolveWheelGesture( + { deltaY: -120, shiftKey: true }, + { surface: "timeline", subtarget: "track", platform, deviceHint: "mouse" }, + profile.wheel, + )).toMatchObject({ + ruleId: "fl-studio.playlist-track-reorder", + operation: "reorder", + target: "track-order", + }); + expect(resolveWheelGesture( + { deltaY: 120, altKey: true, shiftKey: true }, + { surface: "timeline", subtarget: "clip", platform, deviceHint: "mouse" }, + profile.wheel, + )).toMatchObject({ + ruleId: "fl-studio.clip-nudge", + operation: "nudge", + target: "clip-position", + }); + } + }); + + it.each(TEST_PLATFORMS)("resolves exact MIDI editor wheel targets on %s", (platform) => { + const proTools = getMouseBehaviorProfile("pro_tools", platform); + const proToolsEvent = platform === "macos" + ? { deltaY: -120, ctrlKey: true, altKey: true } + : { deltaY: -120, metaKey: true, altKey: true }; + expect(resolveWheelGesture( + proToolsEvent, + { surface: "piano_roll", subtarget: "note", platform }, + proTools.wheel, + )).toMatchObject({ + ruleId: "pro-tools.midi-note-height", + operation: "zoom", + target: "midi-note-height", + }); + + const flStudio = getMouseBehaviorProfile("fl_studio", platform); + expect(resolveWheelGesture( + { deltaY: -120, altKey: true }, + { surface: "piano_roll", subtarget: "note", platform }, + flStudio.wheel, + )).toMatchObject({ + ruleId: "fl-studio.piano-note-property", + operation: "adjust", + target: "note-property", + }); + expect(resolveWheelGesture( + { deltaY: 120, altKey: true, shiftKey: true }, + { surface: "piano_roll", subtarget: "note", platform }, + flStudio.wheel, + )).toMatchObject({ + ruleId: "fl-studio.piano-note-nudge", + operation: "nudge", + target: "note-position", + }); + + const ableton = getMouseBehaviorProfile("ableton_live", platform); + for (const subtarget of ["grid", "keyboard", "note"] as const) { + expect(resolveWheelGesture( + { deltaY: -120, altKey: true }, + { surface: "piano_roll", subtarget, platform }, + ableton.wheel, + )).toMatchObject({ + ruleId: "ableton-live.midi-note-height", + operation: "zoom", + target: "midi-note-height", + }); + } + }); + + it.each(TEST_PLATFORMS)("resolves Audacity vertical-scale targets on %s", (platform) => { + const profile = getMouseBehaviorProfile("audacity", platform); + const cases = [ + { + event: { deltaY: 120, shiftKey: true }, + subtarget: "waveform_scale" as const, + ruleId: "audacity.waveform-scale-pan", + operation: "pan", + target: "waveform-scale", + }, + { + event: platform === "macos" ? { deltaY: -120, metaKey: true } : { deltaY: -120, ctrlKey: true }, + subtarget: "waveform_scale" as const, + ruleId: "audacity.waveform-scale-zoom", + operation: "zoom", + target: "waveform-scale", + }, + { + event: platform === "macos" ? { deltaY: -120, metaKey: true } : { deltaY: -120, ctrlKey: true }, + subtarget: "spectrogram_scale" as const, + ruleId: "audacity.spectrogram-scale-zoom", + operation: "zoom", + target: "spectrogram-scale", + }, + { + event: { deltaY: 120, shiftKey: true }, + subtarget: "spectrogram_scale" as const, + ruleId: "audacity.spectrogram-scale-pan", + operation: "pan", + target: "spectrogram-scale", + }, + { + event: platform === "macos" + ? { deltaY: 120, metaKey: true, shiftKey: true } + : { deltaY: 120, ctrlKey: true, shiftKey: true }, + subtarget: "spectrogram_scale" as const, + ruleId: "audacity.spectrogram-lower-db-limit", + operation: "adjust", + target: "spectrogram-db-floor", + }, + ]; + for (const entry of cases) { + expect(resolveWheelGesture( + entry.event, + { surface: "timeline", subtarget: entry.subtarget, platform, hoveredTargetId: "track-1" }, + profile.wheel, + )).toMatchObject({ + ruleId: entry.ruleId, + operation: entry.operation, + target: entry.target, + preventDefault: true, + }); + } + }); + + it.each(TEST_PLATFORMS)("keeps Ableton automation-lane height and Cubase hit targets distinct on %s", (platform) => { + const ableton = getMouseBehaviorProfile("ableton_live", platform); + expect(resolveWheelGesture( + { deltaY: -120, altKey: true }, + { surface: "timeline", subtarget: "automation_lane", platform, hoveredTargetId: "track-1" }, + ableton.wheel, + )).toMatchObject({ + ruleId: "ableton-live.automation-lane-height", + operation: "resize", + target: "lane-height", + }); + expect(ableton.wheel.rules.some((rule) => rule.id.includes("take-lane"))).toBe(false); + expect(getMouseBehaviorProfile("ardour", platform).wheel.rules.some( + (rule) => rule.id.includes("hovered-track-height"), + )).toBe(false); + + const cubase = getMouseBehaviorProfile("cubase", platform); + expect(resolveWheelGesture( + { deltaY: -120 }, + { surface: "timeline", subtarget: "fade_handle", platform, hoveredTargetId: "clip-1:in" }, + cubase.wheel, + )).toMatchObject({ ruleId: "cubase.fade-handle-adjust", target: "fade-value" }); + expect(resolveWheelGesture( + { deltaY: 120 }, + { surface: "timeline", subtarget: "event_volume", platform, hoveredTargetId: "clip-1" }, + cubase.wheel, + )).toMatchObject({ ruleId: "cubase.event-volume-adjust", target: "event-volume" }); + }); + + it.each(TEST_PLATFORMS)("models Cakewalk console-fader scope safeguards on %s", (platform) => { + const profile = getMouseBehaviorProfile("cakewalk_sonar", platform); + const primaryEvent = platform === "macos" + ? { deltaY: 120, metaKey: true } + : { deltaY: 120, ctrlKey: true }; + const cases = [ + { event: { deltaY: 120 }, ruleId: "cakewalk-sonar.console-fader", operation: "adjust", precision: "normal" }, + { event: { deltaY: 120, shiftKey: true }, ruleId: "cakewalk-sonar.console-fader-fine", operation: "adjust", precision: "fine" }, + { event: primaryEvent, ruleId: "cakewalk-sonar.console-all-faders", operation: "suppress", precision: "normal" }, + { + event: { ...primaryEvent, shiftKey: true }, + ruleId: "cakewalk-sonar.console-selected-faders", + operation: "suppress", + precision: "normal", + }, + ]; + for (const entry of cases) { + const { event, ...expected } = entry; + expect(resolveWheelGesture( + event, + { surface: "parameter", subtarget: "console_fader", platform }, + profile.wheel, + )).toMatchObject(expected); + } + }); + + it.each(TEST_PLATFORMS)("limits Adobe Audition wheel zoom to the ruler on %s", (platform) => { + const profile = getMouseBehaviorProfile("adobe_audition", platform); + expect(resolveWheelGesture( + { deltaY: -120 }, + { surface: "timeline", subtarget: "ruler", platform }, + profile.wheel, + )).toMatchObject({ + ruleId: "adobe-audition.ruler-horizontal-zoom", + operation: "zoom", + target: "timeline", + }); + expect(resolveWheelGesture( + { deltaY: -120 }, + { surface: "timeline", subtarget: "content", platform }, + profile.wheel, + ).matched).toBe(false); + }); + + it.each(TEST_PLATFORMS)("uses Ardour's exact normal and physical-Control fine parameter wheel on %s", (platform) => { + const profile = getMouseBehaviorProfile("ardour", platform); + const fineEvent = platform === "macos" + ? { deltaY: 120, ctrlKey: true } + : { deltaY: 120, ctrlKey: true }; + expect(resolveWheelGesture( + { deltaY: 120 }, + { surface: "parameter", subtarget: "control", platform }, + profile.wheel, + )).toMatchObject({ ruleId: "ardour.parameter-adjust", precision: "normal" }); + expect(resolveWheelGesture( + fineEvent, + { surface: "parameter", subtarget: "control", platform }, + profile.wheel, + )).toMatchObject({ ruleId: "ardour.parameter-fine-adjust", precision: "fine" }); + if (platform === "macos") { + expect(resolveWheelGesture( + { deltaY: 120, metaKey: true }, + { surface: "parameter", subtarget: "control", platform }, + profile.wheel, + )).toMatchObject({ ruleId: "ardour.parameter-unsupported-wheel", operation: "suppress" }); + } + }); + + it("distinguishes macOS physical Control from Command and Windows Meta", () => { + const logicMac = getMouseBehaviorProfile("logic_pro", "macos"); + expect(resolveWheelGesture( + { ctrlKey: true, altKey: true, deltaY: 120 }, + { surface: "timeline", platform: "macos" }, + logicMac.wheel, + ).ruleId).toBe("logic-pro.control-option-horizontal-zoom"); + expect(resolveWheelGesture( + { metaKey: true, altKey: true, deltaY: 120 }, + { surface: "timeline", platform: "macos" }, + logicMac.wheel, + ).matched).toBe(false); + + const bitwigWindows = getMouseBehaviorProfile("bitwig_studio", "windows"); + expect(resolveWheelGesture( + { ctrlKey: true, altKey: true, deltaY: 120 }, + { surface: "timeline", platform: "windows" }, + bitwigWindows.wheel, + ).ruleId).toBe("bitwig-studio.control-alt-horizontal-zoom"); + expect(resolveWheelGesture( + { metaKey: true, altKey: true, deltaY: 120 }, + { surface: "timeline", platform: "windows" }, + bitwigWindows.wheel, + ).matched).toBe(false); + }); + + it("falls back safely to OpenStudio for unknown persisted IDs", () => { + expect(getMouseBehaviorProfile("deleted", "windows").id).toBe("openstudio"); + }); +}); diff --git a/frontend/src/__tests__/mouseModifierIntegration.test.ts b/frontend/src/__tests__/mouseModifierIntegration.test.ts new file mode 100644 index 0000000..1b477e6 --- /dev/null +++ b/frontend/src/__tests__/mouseModifierIntegration.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from "vitest"; +import timelineSource from "../components/Timeline.tsx?raw"; +import pianoRollSource from "../components/PianoRoll.tsx?raw"; +import sortableTrackHeaderSource from "../components/SortableTrackHeader.tsx?raw"; +import preferencesSource from "../components/PreferencesModal.tsx?raw"; +import clipEditingSource from "../store/actions/clipEditing.ts?raw"; + +describe("live mouse-modifier integration", () => { + it("layers active behavior profiles and sparse user overrides at event time", () => { + expect(timelineSource).toContain("const state = useDAWStore.getState();"); + expect(timelineSource).toContain("state.mouseBehaviorProfileId"); + expect(timelineSource).toContain("profile: behaviorProfile.modifiers"); + expect(timelineSource).toContain("overrides: state.mouseModifiers as MouseModifierOverrideMap"); + expect(timelineSource).toContain("platform: toMouseBehaviorPlatform(platform)"); + + expect(sortableTrackHeaderSource).toContain("state.mouseBehaviorProfileId"); + expect(sortableTrackHeaderSource).toContain("profile: behaviorProfile.modifiers"); + expect(sortableTrackHeaderSource).toContain("overrides: state.mouseModifiers as MouseModifierOverrideMap"); + }); + + it("passes the active mouse profile to wheel resolution", () => { + expect(timelineSource).toContain("const behaviorProfile = getMouseBehaviorProfile("); + expect(timelineSource).toContain("}, behaviorProfile.wheel);"); + }); + + it.each([ + "clip_drag", + "clip_resize", + "timeline_click", + "track_header", + "automation_point", + "fade_handle", + "ruler_click", + ])("resolves the %s Preferences context in its real interaction surface", (context) => { + const combinedSource = `${timelineSource}\n${sortableTrackHeaderSource}`; + expect(combinedSource).toContain(`"${context}"`); + }); +}); + +describe("timeline modifier semantics", () => { + it("captures clip drag intent once and routes move variants truthfully", () => { + expect(timelineSource).toContain("isTimelineClipCopyAction(modifierAction)"); + expect(timelineSource).toContain('modifierAction === "constrain"'); + expect(timelineSource).toContain('modifierAction === "bypass_snap"'); + expect(timelineSource).toContain("copyOnDrag,"); + expect(timelineSource).toContain("axisLockRequested:"); + expect(timelineSource).toContain("snapBypassRequested:"); + expect(timelineSource).toContain("Boolean(gesture.snapBypassRequested)"); + expect(timelineSource).toContain("previewTimelineGestureFromPointer(point.x, point.y, event)"); + }); + + it("routes normal, fine, and symmetric resize through the tested geometry helper", () => { + expect(timelineSource).toContain("computeMouseModifierTimelineResize({"); + expect(timelineSource).toContain('gesture.resizeAction === "fine"'); + expect(timelineSource).toContain('gesture.resizeAction === "symmetric"'); + expect(timelineSource).toContain("commitPreviewedResizeTimelineClip("); + }); + + it("implements background seek, range selection, extension, zoom, and razor by semantic action", () => { + for (const action of ["seek", "select_range", "extend_selection", "zoom", "razor"]) { + expect(timelineSource).toContain(`action === "${action}"`); + } + expect(timelineSource).toContain("timelinePointerActionRef.current = { action, startTime: time, dragged: false }"); + expect(timelineSource).toContain("setTimeSelectionDrag({ active: true, startTime: anchor })"); + expect(timelineSource).toContain("seekTo(Math.max(0, (pointerPos.x + scrollX) / pixelsPerSecond))"); + }); + + it("starts audio and MIDI slip edits from the semantic profile action", () => { + expect(timelineSource.match(/modifierAction === "slip"/g)).toHaveLength(2); + expect(timelineSource.match(/slipEditRef\.current = \{/g)).toHaveLength(2); + expect(timelineSource).toContain("originalIsModified: state.isModified"); + expect(timelineSource).toContain("isModified: originalIsModified ?? state.isModified"); + expect(timelineSource).toContain("isTimelineClipGestureLocked(useDAWStore.getState(), [edit.clipId])"); + expect(timelineSource).toContain("cancelActiveTimelineClipGesture()"); + expect(timelineSource).toContain("finalizeSlipTimelineGesture()"); + expect(timelineSource).toContain("slipEditClip(edit.clipId, finalOffset)"); + }); + + it("keeps marquee selection and gain-point creation reachable through semantic actions", () => { + expect(timelineSource).toContain('} else if (action === "seek") {'); + expect(timelineSource).toContain("marqueeRef.current = {"); + expect(timelineSource).toContain('if (action === "constrain" && !clipEditLocked) {'); + expect(timelineSource).toContain("addClipGainPoint(clip.id, timeInClip, gain)"); + }); + + it("hit-tests, starts, updates, and clears profile-driven razor selections", () => { + expect(timelineSource).toContain("const trackHitResult = getTrackAtY("); + expect(timelineSource).toContain("setRazorDrag({"); + expect(timelineSource).toContain("clearRazorEdits();"); + expect(timelineSource).toContain("addRazorEdit(razorDrag.trackId, start, end)"); + expect(timelineSource).toContain("setRazorDrag(null)"); + }); + + it("terminates range and slip gestures outside the Stage and on cancellation", () => { + expect(timelineSource).toContain("const resetRangeGestures = () => {"); + expect(timelineSource).toContain("finalizeSlipTimelineGestureRef.current();"); + expect(timelineSource).toContain("const releasedInsideTimeline = event.target instanceof Node"); + const pointerCancel = timelineSource.slice( + timelineSource.indexOf("const handlePointerCancel = () =>"), + timelineSource.indexOf('window.addEventListener("pointercancel"'), + ); + expect(pointerCancel).toContain("resetRangeGestures();"); + expect(pointerCancel).toContain("timelinePointerActionRef.current = null;"); + }); + + it("allows automation drawing only through the resolved plain timeline action", () => { + const stageMouseDown = timelineSource.slice( + timelineSource.indexOf("const resolvedTimelineAction ="), + timelineSource.indexOf("const time = Math.max", timelineSource.indexOf("const resolvedTimelineAction =")), + ); + expect(stageMouseDown).toContain('resolveLiveMouseModifierAction(e.evt || {}, "timeline_click")'); + expect(stageMouseDown).toContain('resolvedTimelineAction === "seek"'); + expect(stageMouseDown).not.toContain("!e.evt?.shiftKey"); + expect(stageMouseDown).not.toContain("!e.evt?.ctrlKey"); + }); + + it("captures automation action and uses stable-ID track point transactions", () => { + expect(timelineSource).toContain('action === "delete"'); + expect(timelineSource).toContain("resolveAutomationPointDrag(gesture, {"); + expect(timelineSource).toContain("snapEnabled: snapEnabledRef.current"); + expect(timelineSource).toContain("snapTime: snapTimelineTime"); + expect(timelineSource).toContain("gesture.axisLock = preview.axisLock"); + expect(timelineSource).toContain("e.target.position({ x: preview.x, y: preview.y })"); + expect(timelineSource).toContain("getAutomationPointId(point, pi)"); + expect(timelineSource).toContain("setSelectedAutomationPoint(pointTarget)"); + expect(timelineSource).toContain("deleteSelectedAutomationPoint()"); + expect(timelineSource).toContain("beginAutomationPointEdit(pointTarget)"); + expect(timelineSource).toContain("beginAutomationPointCopyEdit(pointTarget)"); + expect(timelineSource).toContain("previewAutomationPointEdit("); + expect(timelineSource).toContain("commitAutomationPointEdit()"); + expect(timelineSource).toContain("automationPointGestureRef.current = {"); + expect(timelineSource).toContain("gesture?.key ==="); + }); + + it("restores an automation drag on every non-commit termination path", () => { + expect(timelineSource).toContain("const cancelActiveAutomationEdit = useCallback(() => {"); + expect(timelineSource).toContain("state.cancelAutomationPointEdit();"); + expect(timelineSource).toContain('window.addEventListener("blur", handleWindowBlur)'); + expect(timelineSource).toContain('window.addEventListener("pointercancel", handlePointerCancel)'); + expect(timelineSource).toContain('window.addEventListener("keydown", handleWindowKeyDown)'); + expect(timelineSource).toContain('event.key !== "Escape"'); + const cleanup = timelineSource.slice( + timelineSource.indexOf("return () => {", timelineSource.indexOf("const handlePointerCancel")), + timelineSource.indexOf("// Time selection drag state"), + ); + expect(cleanup).toContain("cancelActiveAutomationEdit();"); + }); + + it("implements one-transaction fade pointer lifecycle, modifiers, and shape cycling", () => { + expect(timelineSource).toContain("beginFadeHandleGesture"); + expect(timelineSource).toContain('gesture.action === "fine"'); + expect(timelineSource).toContain('gesture.action === "symmetric" ? fadeLength'); + expect(timelineSource).toContain("fadeHandleGestureRef.current = {"); + expect(timelineSource).toContain('fadeAction === "shape_cycle"'); + expect(timelineSource).toContain("cycleTimelineClipFadeShape(clip.id, side)"); + expect(timelineSource).toContain("setClipFadeInShape("); + expect(timelineSource).toContain("setClipFadeOutShape("); + expect(timelineSource).toContain("beginClipFadeEdit(clip.id)"); + expect(timelineSource).toContain("previewClipFades("); + expect(timelineSource).toContain("commitClipFadeEdit(clip.id)"); + expect(timelineSource).toContain("cancelActiveTimelineClipGesture"); + expect(timelineSource).toContain("cancelClipFadeEdit(fadeClipId)"); + expect(timelineSource).toContain("const activeFadeHandle = fadeHandleGestureRef.current"); + expect(timelineSource).toContain("commitClipFadeEdit(activeFadeHandle.clipId)"); + expect(timelineSource).toContain('window.addEventListener("blur", handleWindowBlur)'); + expect(timelineSource).toContain('window.addEventListener("pointercancel", handlePointerCancel)'); + expect(timelineSource).toContain("cancelActiveAutomationEdit();"); + expect(timelineSource).toContain('event.key !== "Escape"'); + expect(timelineSource).toContain("cancelActiveTimelineClipGesture();"); + expect(timelineSource).not.toContain("setClipFades("); + }); + + it("keeps ruler intent stable in global listeners", () => { + expect(timelineSource).toContain('action === "loop_set"'); + expect(timelineSource).toContain('action === "time_select"'); + expect(timelineSource).toContain('action === "zoom_to"'); + expect(timelineSource).toContain('drag.type = "loop-create"'); + expect(timelineSource).toContain('drag.type = "time-select"'); + expect(timelineSource).toContain('drag.type = "zoom-create"'); + expect(timelineSource).toContain('window.addEventListener("mousemove", handleGlobalMouseMove)'); + }); +}); + +describe("track-header modifier semantics", () => { + it("maps selection and undo-aware mute/solo operations without raw modifier branches", () => { + expect(sortableTrackHeaderSource).toContain('action === "select"'); + expect(sortableTrackHeaderSource).toContain('action === "toggle_select"'); + expect(sortableTrackHeaderSource).toContain('action === "range_select"'); + expect(sortableTrackHeaderSource).toContain('action === "solo"'); + expect(sortableTrackHeaderSource).toContain('action === "mute"'); + expect(sortableTrackHeaderSource).toContain("selectTrack(track.id, { ctrl: true })"); + expect(sortableTrackHeaderSource).toContain("selectTrack(track.id, { shift: true })"); + expect(sortableTrackHeaderSource).toContain("void toggleTrackSolo(track.id)"); + expect(sortableTrackHeaderSource).toContain("void toggleTrackMute(track.id)"); + }); + + it("does not apply header actions to embedded controls", () => { + expect(sortableTrackHeaderSource).toContain( + '"button, input, select, [data-color-bar], [data-no-select]"', + ); + }); +}); + +describe("intentional undo-safety guards", () => { + it("previews stretch, restores the preview, and commits through the undo-safe action", () => { + expect(timelineSource).toContain('gesture.resizeAction === "stretch"'); + expect(timelineSource).toContain("previewResizeTimelineClip(gesture.clipId, isMidi, {"); + expect(timelineSource).toContain("const stretched = await useDAWStore.getState().stretchClip("); + expect(timelineSource).toContain("restoreTimelineGestureUndo();"); + expect(timelineSource).toContain("getSafeMouseModifierNoop(context, action)"); + expect(preferencesSource).toContain("getSafeMouseModifierNoop(key, action)"); + }); + + it("reverts an in-progress stretch on Escape, blur, and pointer cancellation", () => { + expect(timelineSource).toContain('event.key !== "Escape"'); + expect(timelineSource).toContain('window.addEventListener("blur", handleWindowBlur)'); + expect(timelineSource).toContain('window.addEventListener("pointercancel", handlePointerCancel)'); + expect(timelineSource).toContain("if (hadTimelineDrag && !activeGesture.isFadeDrag)"); + expect(timelineSource).toContain("restoreTimelineGestureUndo();"); + }); + + it("supports master automation gestures and commits movement only on drag end", () => { + expect(timelineSource).toContain('const key = `master:${lane.id}:${pointId}`'); + expect(timelineSource).toContain("setSelectedAutomationPoint(pointTarget)"); + expect(timelineSource).toContain("deleteSelectedAutomationPoint()"); + expect(timelineSource).toContain("beginAutomationPointEdit(pointTarget)"); + expect(timelineSource).toContain("previewAutomationPointEdit("); + expect(timelineSource).toContain("commitAutomationPointEdit()"); + expect(timelineSource).toContain("resolveAutomationPointDrag(gesture, {"); + + const masterSection = timelineSource.slice( + timelineSource.indexOf("const renderMasterAutomationLanes"), + timelineSource.indexOf("// Render razor edits"), + ); + const dragMoveSection = masterSection.slice( + masterSection.indexOf("onDragMove="), + masterSection.indexOf("onDragEnd="), + ); + expect(dragMoveSection).not.toContain("commitAutomationPointEdit()"); + }); +}); + +describe("context-specific profile wheel integration", () => { + it("hit-tests FL Studio and Cubase timeline targets before resolving the profile", () => { + expect(timelineSource).toContain("findTimelineClipHit(timelineClipHitMapRef.current, stageX, mouseY)"); + expect(timelineSource).toContain('contextualSubtarget = "fade_handle"'); + expect(timelineSource).toContain('contextualSubtarget = "event_volume"'); + expect(timelineSource).toContain('gesture.target === "track-order"'); + expect(timelineSource).toContain('gesture.target === "clip-position"'); + expect(timelineSource).toContain("state.beginTrackReorderEdit(target.trackId)"); + expect(timelineSource).toContain("state.commitTrackReorderEdit(target.trackId)"); + expect(timelineSource).toContain("previewTrackReorder(trackId, direction)"); + expect(timelineSource).toContain("state.beginClipNudgeEdit(target.clipId)"); + expect(timelineSource).toContain("state.commitClipNudgeEdit(target.clipId)"); + expect(timelineSource).toContain("state.previewClipNudge("); + expect(timelineSource).toContain("state.beginClipFadeEdit(target.clipId)"); + expect(timelineSource).toContain("state.previewClipFades(clipHit.clipId"); + expect(timelineSource).toContain("state.commitClipFadeEdit(target.clipId)"); + expect(timelineSource).toContain("state.beginClipVolumeEdit(target.clipId)"); + expect(timelineSource).toContain("state.commitClipVolumeEdit(target.clipId)"); + expect(timelineSource).toContain("timelineContextWheelEditControllerRef.current?.touch({"); + expect(timelineSource).toContain("createTimelineContextWheelAccumulator(100)"); + expect(timelineSource).toContain("createTimelineContextWheelAccumulator(1)"); + const wheelSection = timelineSource.slice( + timelineSource.indexOf("const handleWheel = (e: WheelEvent) =>"), + timelineSource.indexOf('container.addEventListener("wheel", handleWheel'), + ); + expect(wheelSection).toContain("getAccumulatedWheelNudgeDirection("); + expect(wheelSection).toContain("getAccumulatedWheelStepCount("); + expect(wheelSection).toContain("`track-reorder:${trackId}`"); + expect(wheelSection).toContain("`clip-nudge:${clipHit.clipId}`"); + expect(wheelSection).toContain("`clip-fade:${clipHit.clipId}:${side}`"); + expect(wheelSection).toContain("`clip-volume:${clipHit.clipId}`"); + expect(wheelSection).toContain("timelineDiscreteWheelAccumulatorRef.current?.reset()"); + expect(wheelSection).toContain("timelineSmoothWheelAccumulatorRef.current?.reset()"); + expect(wheelSection).not.toContain("getWheelNudgeDirection("); + expect(wheelSection).not.toContain("getWheelStepCount("); + expect(wheelSection).not.toContain("reorderTrack("); + expect(wheelSection).not.toContain("nudgeClips("); + }); + + it("implements Audacity waveform and spectrogram scale ownership at an explicit scale strip", () => { + expect(timelineSource).toContain("TIMELINE_VERTICAL_SCALE_WIDTH"); + expect(timelineSource).toContain("getTimelineVerticalScaleSubtarget({"); + expect(timelineSource).toContain('gesture.target === "spectrogram-db-floor"'); + expect(timelineSource).toContain("waveformScaleView.spectrogramDbFloor"); + expect(timelineSource).toContain("verticalOffset: waveformScaleView.verticalOffset"); + expect(timelineSource).toContain("spectrogramScale"); + expect(timelineSource).toContain("computeSpectrogramBandGeometry({"); + expect(timelineSource).toContain("height={geometry.height}"); + }); + + it("uses the shared audio/MIDI/recording extent for horizontal Timeline scrolling", () => { + const wheelSection = timelineSource.slice( + timelineSource.indexOf("const handleWheel = (e: WheelEvent) =>"), + timelineSource.indexOf('container.addEventListener("wheel", handleWheel'), + ); + expect(wheelSection).toContain("getTimelineHorizontalScrollMax("); + expect(wheelSection).toContain("state.recordingClips.length > 0"); + expect(timelineSource).toContain("const maxClipEnd = getTimelineVisibleContentEnd("); + }); + + it("resizes the exact Ableton automation lane and renders its stored height", () => { + expect(timelineSource).toContain('gesture.target === "lane-height"'); + expect(timelineSource).toContain("const hitLane = visibleLanes[trackHit.laneIndex]"); + expect(timelineSource).toContain("computeWheelResizedSize({"); + expect(timelineSource).toContain("createWheelEditBurstController({"); + expect(timelineSource).toContain("beginAutomationLaneHeightEdit("); + expect(timelineSource).toContain("setAutomationLaneHeight("); + expect(timelineSource).toContain("commitAutomationLaneHeightEdit("); + expect(timelineSource).toContain("timelineContextWheelEditControllerRef.current?.dispose()"); + expect(timelineSource).toContain("const laneH = getAutomationLaneHeight(lane)"); + expect(timelineSource).toContain("getAutomationLaneOffset(visibleLanes, laneIdx)"); + }); + + it("hit-tests Piano Roll notes and routes zoom, nudge, and property edits through undo-aware actions", () => { + expect(pianoRollSource).toContain("const wheelHit = isInsideStage"); + expect(pianoRollSource).toContain("hitTestPianoRoll(stageX, stageY, {"); + expect(pianoRollSource).toContain('gesture.target === "midi-note-height"'); + expect(pianoRollSource).toContain("computeAnchoredVerticalWheelZoom({"); + expect(pianoRollSource).toContain('closest(".piano-roll-key-viewport")'); + expect(pianoRollSource).toContain("getMidiNoteHeightZoomPointerOffset({"); + expect(pianoRollSource).toContain('gesture.target === "note-position"'); + expect(pianoRollSource).toContain('gesture.target === "note-property"'); + expect(pianoRollSource).toContain("commitMIDIClipEvents("); + expect(pianoRollSource).toContain("contextWheelEditControllerRef.current?.dispose()"); + expect(pianoRollSource).toContain("createPianoRollContextWheelAccumulator()"); + const wheelSection = pianoRollSource.slice( + pianoRollSource.indexOf("const handleWheel = (event: WheelEvent) =>"), + pianoRollSource.indexOf('container.addEventListener("wheel", handleWheel'), + ); + expect(wheelSection).toContain("getAccumulatedWheelNudgeDirection("); + expect(wheelSection).toContain("getAccumulatedWheelStepCount("); + expect(wheelSection).toContain("`note-nudge:${sessionKey}:${trackId}:${clipId}:${initialNoteId}`"); + expect(wheelSection).toContain("`note-property:${sessionKey}:${trackId}:${clipId}:${initialNoteId}:${propertyKey}`"); + expect(wheelSection).toContain("contextWheelAccumulatorRef.current?.reset()"); + expect(wheelSection).not.toContain("getWheelNudgeDirection("); + expect(wheelSection).not.toContain("getWheelStepCount("); + expect(wheelSection).toContain("rebuildMIDIEventsForNotes("); + expect(wheelSection).toContain("previewMIDIClipEvents(trackId, clipId"); + expect(wheelSection).toContain("contextWheelEditControllerRef.current?.touch(target)"); + expect(wheelSection).not.toContain("moveMIDINotes("); + expect(wheelSection).not.toContain("commitMIDIClipEvents("); + }); + + it("moves MIDI and audio clips together while skipping locked/no-op nudges", () => { + expect(clipEditingSource).toContain("let touchedMIDI = false"); + expect(clipEditingSource).toContain("selectedState.selectedClipIds.includes(clip.id) && !isClipEditLocked(selectedState, clip)"); + expect(clipEditingSource).toContain("if (touchedMIDI) syncMIDITracksForTimelineClips(get, get().tracks)"); + expect(clipEditingSource).toContain("clipPositions.size === 0"); + }); +}); diff --git a/frontend/src/__tests__/mouseModifierResolver.test.ts b/frontend/src/__tests__/mouseModifierResolver.test.ts new file mode 100644 index 0000000..2b4494c --- /dev/null +++ b/frontend/src/__tests__/mouseModifierResolver.test.ts @@ -0,0 +1,503 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MOUSE_MODIFIER_PRECEDENCE, + MOUSE_MODIFIER_ACTIONS, + MOUSE_MODIFIER_CONTEXTS, + OPENSTUDIO_MOUSE_MODIFIER_MAPPINGS, + OPENSTUDIO_MOUSE_MODIFIER_PROFILE, + canonicalizeMouseModifierCombination, + isMouseModifierActionForContext, + normalizeMouseModifiers, + resolveMouseModifier, + resolveMouseModifierAction, + type MouseLogicalModifier, + type MouseModifierCombination, + type MouseModifierContext, + type MouseModifierPlatform, + type MouseModifierProfile, + type PointerModifierEventLike, +} from "../utils/mouseModifierResolver"; + +const logicalModifierOrder = [ + "primary", + "secondary", + "alt", + "shift", +] as const satisfies readonly MouseLogicalModifier[]; + +interface ContextDefaults { + none: string; + primary: string; + alt: string; + shift: string; +} + +const contextDefaults: Record = { + clip_drag: { none: "move", primary: "copy", alt: "bypass_snap", shift: "constrain" }, + clip_resize: { none: "resize", primary: "fine", alt: "stretch", shift: "symmetric" }, + timeline_click: { none: "seek", primary: "select_range", alt: "razor", shift: "extend_selection" }, + track_header: { none: "select", primary: "toggle_select", alt: "solo", shift: "range_select" }, + automation_point: { none: "move", primary: "fine", alt: "delete", shift: "constrain_y" }, + fade_handle: { none: "adjust", primary: "fine", alt: "shape_cycle", shift: "symmetric" }, + ruler_click: { none: "seek", primary: "loop_set", alt: "zoom_to", shift: "time_select" }, +}; + +function eventForLogicalModifiers( + platform: MouseModifierPlatform, + active: ReadonlySet, +): PointerModifierEventLike { + const primary = active.has("primary"); + const secondary = active.has("secondary"); + return { + ctrlKey: platform === "macos" ? secondary : primary, + metaKey: platform === "macos" ? primary : secondary, + altKey: active.has("alt"), + shiftKey: active.has("shift"), + }; +} + +function expectedCombination(active: ReadonlySet): MouseModifierCombination { + const ordered = logicalModifierOrder.filter((modifier) => active.has(modifier)); + return ordered.length === 0 ? "none" : ordered.join("+") as MouseModifierCombination; +} + +function expectedDefaultAction( + context: MouseModifierContext, + active: ReadonlySet, +): string { + const defaults = contextDefaults[context]; + if (active.size === 0) return defaults.none; + if (context === "clip_drag" && active.size === 2 && active.has("primary") && active.has("shift")) { + return "slip"; + } + if (active.has("primary")) return defaults.primary; + // OpenStudio intentionally leaves the secondary modifier unassigned. It is + // skipped during precedence fallback rather than becoming a plain click. + if (active.has("alt")) return defaults.alt; + if (active.has("shift")) return defaults.shift; + return "none"; +} + +const exhaustiveCases = (["windows", "macos"] as const).flatMap((platform) => ( + MOUSE_MODIFIER_CONTEXTS.flatMap((context) => ( + Array.from({ length: 16 }, (_, mask) => { + const active = new Set( + logicalModifierOrder.filter((_, index) => Boolean(mask & (1 << index))), + ); + return { + label: `${platform} ${context} ${expectedCombination(active)}`, + platform, + context, + active, + event: eventForLogicalModifiers(platform, active), + expectedAction: expectedDefaultAction(context, active), + expectedCombination: expectedCombination(active), + }; + }) + )) +)); + +describe("mouse modifier normalization", () => { + it.each([ + { + label: "Windows Control/Meta", + platform: "windows" as const, + event: { ctrlKey: true, metaKey: true }, + }, + { + label: "macOS Command/physical Control", + platform: "macos" as const, + event: { ctrlKey: true, metaKey: true }, + }, + ])("keeps primary and secondary independent for $label", ({ platform, event }) => { + expect(normalizeMouseModifiers(event, platform)).toMatchObject({ + primary: true, + secondary: true, + alt: false, + shift: false, + combination: "primary+secondary", + }); + }); + + it("maps raw modifiers to their correct semantic role per platform", () => { + expect(normalizeMouseModifiers({ ctrlKey: true }, "windows")).toMatchObject({ + primary: true, + secondary: false, + combination: "primary", + }); + expect(normalizeMouseModifiers({ metaKey: true }, "windows")).toMatchObject({ + primary: false, + secondary: true, + combination: "secondary", + }); + expect(normalizeMouseModifiers({ metaKey: true }, "macos")).toMatchObject({ + primary: true, + secondary: false, + combination: "primary", + }); + expect(normalizeMouseModifiers({ ctrlKey: true }, "macos")).toMatchObject({ + primary: false, + secondary: true, + combination: "secondary", + }); + expect(normalizeMouseModifiers({ altKey: true }, "macos")).toMatchObject({ + alt: true, + combination: "alt", + }); + }); + + it.each((['windows', 'macos'] as const).flatMap((platform) => ( + Array.from({ length: 16 }, (_, mask) => { + const active = new Set( + logicalModifierOrder.filter((_, index) => Boolean(mask & (1 << index))), + ); + return { platform, active, expected: expectedCombination(active) }; + }) + )))("canonicalizes every exact $platform modifier combination as $expected", ({ + platform, + active, + expected, + }) => { + const result = normalizeMouseModifiers(eventForLogicalModifiers(platform, active), platform); + expect(result.combination).toBe(expected); + expect(result.active).toEqual(logicalModifierOrder.filter((modifier) => active.has(modifier))); + }); + + it.each([ + ["ctrl", "primary"], + ["Command", "primary"], + ["cmd + option + SHIFT", "primary+alt+shift"], + ["secondary+alt", "secondary+alt"], + ["shift + primary + secondary + alt", "primary+secondary+alt+shift"], + ["none", "none"], + ] as const)("canonicalizes the mapping key %s", (input, expected) => { + expect(canonicalizeMouseModifierCombination(input)).toBe(expected); + }); + + it.each(["", "meta", "control", "primary+primary", "none+shift", "ctrl+command", "banana"])( + "rejects ambiguous or malformed mapping key %s", + (input) => { + expect(canonicalizeMouseModifierCombination(input)).toBeNull(); + }, + ); +}); + +describe("OpenStudio mouse modifier profile", () => { + it.each(exhaustiveCases)("resolves $label", ({ + platform, + context, + event, + expectedAction, + expectedCombination: combination, + }) => { + const result = resolveMouseModifier(event, context, { platform }); + expect(result.action).toBe(expectedAction); + expect(result.modifiers.combination).toBe(combination); + expect(result.profileId).toBe("openstudio"); + expect(result.context).toBe(context); + expect(result.isNoop).toBe(expectedAction === "none"); + + if (expectedAction === "none") { + expect(result).toMatchObject({ + source: "none", + matchKind: "none", + matchedCombination: null, + matched: false, + }); + } else { + const mappings = OPENSTUDIO_MOUSE_MODIFIER_MAPPINGS[context] as Partial< + Record + >; + const exact = Object.prototype.hasOwnProperty.call(mappings, combination); + expect(result).toMatchObject({ + source: "profile", + matchKind: exact ? "exact" : "precedence", + matched: true, + }); + } + }); + + it("exposes the current Preferences contexts, actions, and defaults", () => { + expect(MOUSE_MODIFIER_CONTEXTS).toEqual([ + "clip_drag", + "clip_resize", + "timeline_click", + "track_header", + "automation_point", + "fade_handle", + "ruler_click", + ]); + expect(DEFAULT_MOUSE_MODIFIER_PRECEDENCE).toEqual([ + "primary", + "secondary", + "alt", + "shift", + ]); + + for (const context of MOUSE_MODIFIER_CONTEXTS) { + for (const action of Object.values(OPENSTUDIO_MOUSE_MODIFIER_MAPPINGS[context])) { + expect(isMouseModifierActionForContext(context, action)).toBe(true); + expect(MOUSE_MODIFIER_ACTIONS[context]).toContain(action); + } + } + }); + + it("returns the semantic action directly through the convenience API", () => { + expect(resolveMouseModifierAction( + { ctrlKey: true }, + "clip_drag", + { platform: "windows" }, + )).toBe("copy"); + expect(resolveMouseModifierAction( + { metaKey: true }, + "clip_drag", + { platform: "macos" }, + )).toBe("copy"); + }); + + it.each(["windows", "macos"] as const)( + "keeps OpenStudio slip and razor gestures semantic on %s", + (platform) => { + expect(resolveMouseModifierAction( + eventForLogicalModifiers(platform, new Set(["primary", "shift"])), + "clip_drag", + { platform }, + )).toBe("slip"); + expect(resolveMouseModifierAction( + eventForLogicalModifiers(platform, new Set(["alt"])), + "timeline_click", + { platform }, + )).toBe("razor"); + }, + ); +}); + +describe("exact combinations and precedence", () => { + const combinedProfile: MouseModifierProfile = { + id: "combined", + name: "Combined", + mappings: { + ...OPENSTUDIO_MOUSE_MODIFIER_MAPPINGS, + clip_drag: { + ...OPENSTUDIO_MOUSE_MODIFIER_MAPPINGS.clip_drag, + secondary: "select", + "primary+shift": "constrain", + }, + }, + }; + + it.each(["windows", "macos"] as const)( + "lets an exact combined mapping win on %s", + (platform) => { + const active = new Set(["primary", "shift"]); + expect(resolveMouseModifier( + eventForLogicalModifiers(platform, active), + "clip_drag", + { platform, profile: combinedProfile }, + )).toMatchObject({ + action: "constrain", + source: "profile", + matchKind: "exact", + matchedCombination: "primary+shift", + }); + }, + ); + + it.each(["windows", "macos"] as const)( + "uses a mapped physical secondary modifier before Alt and Shift on %s", + (platform) => { + const active = new Set(["secondary", "alt", "shift"]); + expect(resolveMouseModifier( + eventForLogicalModifiers(platform, active), + "clip_drag", + { platform, profile: combinedProfile }, + )).toMatchObject({ + action: "select", + matchKind: "precedence", + matchedCombination: "secondary", + }); + }, + ); + + it("allows profiles to change single-modifier fallback order", () => { + const shiftFirst: MouseModifierProfile = { + ...combinedProfile, + id: "shift-first", + modifierPrecedence: ["shift", "alt", "primary", "secondary"], + mappings: { + ...combinedProfile.mappings, + clip_drag: OPENSTUDIO_MOUSE_MODIFIER_MAPPINGS.clip_drag, + }, + }; + expect(resolveMouseModifier( + { ctrlKey: true, altKey: true, shiftKey: true }, + "clip_drag", + { platform: "windows", profile: shiftFirst }, + )).toMatchObject({ + action: "constrain", + matchKind: "precedence", + matchedCombination: "shift", + }); + }); +}); + +describe("user override maps and safe no-op behavior", () => { + it.each(["windows", "macos"] as const)( + "accepts the legacy ctrl override as semantic primary on %s", + (platform) => { + const event = eventForLogicalModifiers(platform, new Set(["primary"])); + expect(resolveMouseModifier(event, "clip_drag", { + platform, + overrides: { clip_drag: { ctrl: "select" } }, + })).toMatchObject({ + action: "select", + source: "override", + matchKind: "exact", + matchedCombination: "primary", + }); + }, + ); + + it("uses an exact combined override before profile and fallback mappings", () => { + expect(resolveMouseModifier( + { ctrlKey: true, altKey: true, shiftKey: true }, + "clip_drag", + { + platform: "windows", + overrides: { + clip_drag: { + primary: "select", + "primary+alt+shift": "constrain", + }, + }, + }, + )).toMatchObject({ + action: "constrain", + source: "override", + matchKind: "exact", + matchedCombination: "primary+alt+shift", + }); + }); + + it("honors explicit none without falling through to a destructive action", () => { + expect(resolveMouseModifier( + { ctrlKey: true, shiftKey: true }, + "clip_drag", + { + platform: "windows", + overrides: { clip_drag: { "primary+shift": "none" } }, + }, + )).toMatchObject({ + action: "none", + source: "override", + matchKind: "exact", + matched: true, + isNoop: true, + }); + }); + + it.each([ + ["action from another context", { clip_drag: { primary: "delete" } }], + ["unknown action", { clip_drag: { primary: "launch_missiles" } }], + ["non-string action", { clip_drag: { primary: 42 } }], + ["undefined persisted value", { clip_drag: { primary: undefined } }], + ] as const)("turns an invalid %s into a safe no-op", (_label, overrides) => { + expect(resolveMouseModifier( + { ctrlKey: true }, + "clip_drag", + { platform: "windows", overrides }, + )).toMatchObject({ + action: "none", + source: "override", + matched: true, + isNoop: true, + }); + }); + + it("does not turn an unmapped modified gesture into a plain click", () => { + expect(resolveMouseModifier( + { metaKey: true }, + "automation_point", + { platform: "windows" }, + )).toMatchObject({ + action: "none", + source: "none", + matched: false, + }); + }); + + it("does not mutate the factory profile while resolving overrides", () => { + const before = JSON.stringify(OPENSTUDIO_MOUSE_MODIFIER_PROFILE); + resolveMouseModifier({ ctrlKey: true }, "timeline_click", { + platform: "windows", + overrides: { timeline_click: { primary: "zoom" } }, + }); + expect(JSON.stringify(OPENSTUDIO_MOUSE_MODIFIER_PROFILE)).toBe(before); + expect(resolveMouseModifierAction( + { ctrlKey: true }, + "timeline_click", + { platform: "windows" }, + )).toBe("select_range"); + }); + + it("rejects AltGraph instead of treating it as a Ctrl+Alt gesture", () => { + expect(resolveMouseModifier( + { + ctrlKey: true, + altKey: true, + getModifierState: (modifier) => modifier === "AltGraph", + }, + "clip_drag", + { platform: "windows" }, + )).toMatchObject({ + action: "none", + source: "unsupported", + matchKind: "none", + matched: false, + modifiers: { altGraph: true }, + }); + }); + + it("fails safely when an imported runtime context is unknown", () => { + const invalidContext = "unknown_surface" as MouseModifierContext; + expect(resolveMouseModifier( + {}, + invalidContext, + { platform: "windows" }, + )).toMatchObject({ + action: "none", + source: "unsupported", + matched: false, + }); + }); +}); + +describe("keyboard-layout independence", () => { + it.each([ + { layout: "QWERTY", key: "z", code: "KeyZ" }, + { layout: "QWERTZ", key: "z", code: "KeyY" }, + { layout: "AZERTY", key: "q", code: "KeyA" }, + { layout: "Dvorak", key: ";", code: "KeyZ" }, + ])("resolves only pointer modifiers on $layout", (layoutEvent) => { + const event = { + ...layoutEvent, + ctrlKey: true, + shiftKey: true, + button: 0, + }; + expect(resolveMouseModifier(event, "ruler_click", { platform: "windows" })) + .toMatchObject({ + action: "loop_set", + matchKind: "precedence", + matchedCombination: "primary", + }); + }); + + it("resolves identically when browser pointer metadata is missing", () => { + expect(resolveMouseModifierAction( + { ctrlKey: true }, + "track_header", + { platform: "other" }, + )).toBe("toggle_select"); + }); +}); diff --git a/frontend/src/__tests__/mouseModifierTimelineBehaviors.test.ts b/frontend/src/__tests__/mouseModifierTimelineBehaviors.test.ts new file mode 100644 index 0000000..c80a016 --- /dev/null +++ b/frontend/src/__tests__/mouseModifierTimelineBehaviors.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + CLIP_FADE_SHAPE_COUNT, + FINE_MOUSE_POINTER_SCALE, + SAFE_MOUSE_MODIFIER_NOOPS, + computeMouseModifierTimelineResize, + getNextClipFadeShape, + getSafeMouseModifierNoop, +} from "../utils/mouseModifierTimelineBehaviors"; + +const base = { + kind: "resize-right" as const, + isMidi: false, + originalStartTime: 4, + originalDuration: 6, + originalOffset: 3, + sourceLength: 20, +}; + +describe("mouse-modifier timeline resize behavior", () => { + it("preserves ordinary resize behavior", () => { + expect(computeMouseModifierTimelineResize({ + ...base, + action: "resize", + deltaTime: 2, + })).toEqual({ startTime: 4, duration: 8, offset: 3 }); + }); + + it("scales fine resize movement without changing its anchor", () => { + expect(FINE_MOUSE_POINTER_SCALE).toBe(0.1); + expect(computeMouseModifierTimelineResize({ + ...base, + action: "fine", + deltaTime: 2, + })).toEqual({ startTime: 4, duration: 6.2, offset: 3 }); + }); + + it("previews stretch geometry without trimming source material", () => { + expect(computeMouseModifierTimelineResize({ + ...base, + action: "stretch", + deltaTime: 3, + })).toEqual({ startTime: 4, duration: 9, offset: 4.5 }); + }); + + it.each([ + { + kind: "resize-left" as const, + deltaTime: 1, + expected: { startTime: 5, duration: 4, offset: 4 }, + }, + { + kind: "resize-right" as const, + deltaTime: 1, + expected: { startTime: 3, duration: 8, offset: 2 }, + }, + ])("keeps the clip center fixed for $kind", ({ kind, deltaTime, expected }) => { + expect(computeMouseModifierTimelineResize({ + ...base, + kind, + action: "symmetric", + deltaTime, + })).toEqual(expected); + }); + + it("clamps symmetric expansion at the timeline and source boundaries", () => { + expect(computeMouseModifierTimelineResize({ + kind: "resize-left", + action: "symmetric", + isMidi: false, + originalStartTime: 1, + originalDuration: 4, + originalOffset: 1, + sourceLength: 6, + deltaTime: -10, + })).toEqual({ startTime: 0, duration: 6, offset: 0 }); + + expect(computeMouseModifierTimelineResize({ + kind: "resize-right", + action: "symmetric", + isMidi: false, + originalStartTime: 5, + originalDuration: 4, + originalOffset: 2, + sourceLength: 7, + deltaTime: 10, + })).toEqual({ startTime: 4, duration: 6, offset: 1 }); + }); + + it("honors snapping before applying symmetric bounds", () => { + expect(computeMouseModifierTimelineResize({ + ...base, + action: "symmetric", + deltaTime: 0.4, + snapTime: (time) => Math.round(time), + })).toEqual({ startTime: 4, duration: 6, offset: 3 }); + }); +}); + +describe("explicit safe mouse-modifier no-ops", () => { + it("has no guarded modifier operations after stretch and master automation integration", () => { + expect(SAFE_MOUSE_MODIFIER_NOOPS).toEqual([]); + }); + + it("does not classify implemented operations as no-ops", () => { + expect(getSafeMouseModifierNoop("clip_resize", "stretch")).toBeNull(); + expect(getSafeMouseModifierNoop("fade_handle", "shape_cycle")).toBeNull(); + expect(getSafeMouseModifierNoop("clip_resize", "fine")).toBeNull(); + expect(getSafeMouseModifierNoop("clip_drag", "move")).toBeNull(); + }); + +}); + +describe("fade-shape cycling", () => { + it("advances each supported shape and wraps the final shape to linear", () => { + expect(CLIP_FADE_SHAPE_COUNT).toBe(5); + expect(Array.from( + { length: CLIP_FADE_SHAPE_COUNT }, + (_, shape) => getNextClipFadeShape(shape), + )).toEqual([1, 2, 3, 4, 0]); + }); + + it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY])( + "recovers malformed shape %s through the first valid shape", + (shape) => { + expect(getNextClipFadeShape(shape)).toBe(1); + }, + ); +}); diff --git a/frontend/src/__tests__/namAdvancedControlExposure.test.ts b/frontend/src/__tests__/namAdvancedControlExposure.test.ts new file mode 100644 index 0000000..772f34a --- /dev/null +++ b/frontend/src/__tests__/namAdvancedControlExposure.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + NAM_RACK_ADVANCED_CONTROL_IDS, + NAM_RACK_ADVANCED_ONLY_CONTROL_IDS, + namRackAdvancedStageForCompactModule, +} from "../components/NAMRackMixer"; + +describe("NAM Rack advanced-control exposure", () => { + it("keeps every advanced-only parameter in the complete supported registry", () => { + for (const stageId of Object.keys(NAM_RACK_ADVANCED_ONLY_CONTROL_IDS)) { + const stage = stageId as keyof typeof NAM_RACK_ADVANCED_ONLY_CONTROL_IDS; + const supported = NAM_RACK_ADVANCED_CONTROL_IDS[stage] as readonly string[]; + for (const paramId of NAM_RACK_ADVANCED_ONLY_CONTROL_IDS[stage]) { + expect(supported).toContain(paramId); + } + } + }); + + it("exposes advanced affordances only for stages with hidden controls", () => { + expect(namRackAdvancedStageForCompactModule("gate")).toBe("gate"); + expect(namRackAdvancedStageForCompactModule("mod")).toBe("mod"); + expect(namRackAdvancedStageForCompactModule("delay")).toBe("delay"); + expect(namRackAdvancedStageForCompactModule("amp-nam")).toBeNull(); + expect(namRackAdvancedStageForCompactModule("cab-ir")).toBeNull(); + expect(namRackAdvancedStageForCompactModule("eq")).toBeNull(); + expect(namRackAdvancedStageForCompactModule("reverb")).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/namAmpAssetIntegrity.test.ts b/frontend/src/__tests__/namAmpAssetIntegrity.test.ts new file mode 100644 index 0000000..a978007 --- /dev/null +++ b/frontend/src/__tests__/namAmpAssetIntegrity.test.ts @@ -0,0 +1,83 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this asset audit in Node. +import { readFileSync } from "node:fs"; +import sharp from "sharp"; +import { describe, expect, it } from "vitest"; + +const readAsset = (name: string) => readFileSync( + new URL(`../assets/nam/design/bodies/${name}`, import.meta.url), +); + +describe("NAM Amp GAIN artwork integrity", () => { + it("changes only the interior label patch and preserves the approved visible border", async () => { + const [original, gainBody, originalOnBlack, gainBodyOnBlack, originalOnWhite, gainBodyOnWhite] = await Promise.all([ + sharp(readAsset("amp-head-body-v4.webp")) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }), + sharp(readAsset("amp-head-body-v5.webp")) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }), + sharp(readAsset("amp-head-body-v4.webp")) + .flatten({ background: "#000000" }) + .raw() + .toBuffer({ resolveWithObject: true }), + sharp(readAsset("amp-head-body-v5.webp")) + .flatten({ background: "#000000" }) + .raw() + .toBuffer({ resolveWithObject: true }), + sharp(readAsset("amp-head-body-v4.webp")) + .flatten({ background: "#ffffff" }) + .raw() + .toBuffer({ resolveWithObject: true }), + sharp(readAsset("amp-head-body-v5.webp")) + .flatten({ background: "#ffffff" }) + .raw() + .toBuffer({ resolveWithObject: true }), + ]); + + expect(gainBody.info).toMatchObject({ + width: original.info.width, + height: original.info.height, + channels: 4, + }); + + const patch = { left: 300, top: 580, right: 425, bottom: 665 }; + let alphaDifferences = 0; + let exteriorVisibleDifferencesOverOne = 0; + let interiorColorDifferences = 0; + for (let y = 0; y < original.info.height; y += 1) { + for (let x = 0; x < original.info.width; x += 1) { + const offset = (y * original.info.width + x) * 4; + if (original.data[offset + 3] !== gainBody.data[offset + 3]) { + alphaDifferences += 1; + } + const colorChanged = original.data[offset] !== gainBody.data[offset] + || original.data[offset + 1] !== gainBody.data[offset + 1] + || original.data[offset + 2] !== gainBody.data[offset + 2]; + const insidePatch = x >= patch.left && x < patch.right + && y >= patch.top && y < patch.bottom; + if (colorChanged && insidePatch) interiorColorDifferences += 1; + if (!insidePatch) { + const flattenedOffset = (y * original.info.width + x) * 3; + const visibleDelta = Math.max( + Math.abs(originalOnBlack.data[flattenedOffset] - gainBodyOnBlack.data[flattenedOffset]), + Math.abs(originalOnBlack.data[flattenedOffset + 1] - gainBodyOnBlack.data[flattenedOffset + 1]), + Math.abs(originalOnBlack.data[flattenedOffset + 2] - gainBodyOnBlack.data[flattenedOffset + 2]), + Math.abs(originalOnWhite.data[flattenedOffset] - gainBodyOnWhite.data[flattenedOffset]), + Math.abs(originalOnWhite.data[flattenedOffset + 1] - gainBodyOnWhite.data[flattenedOffset + 1]), + Math.abs(originalOnWhite.data[flattenedOffset + 2] - gainBodyOnWhite.data[flattenedOffset + 2]), + ); + if (visibleDelta > 1) exteriorVisibleDifferencesOverOne += 1; + } + } + } + + expect(alphaDifferences).toBe(0); + // WebP may normalize hidden RGB under fully transparent pixels. Verify the + // actual composited edge on both dark and light stages instead: no exterior + // pixel may drift by more than one 8-bit level, ruling out visible halos. + expect(exteriorVisibleDifferencesOverOne).toBe(0); + expect(interiorColorDifferences).toBeGreaterThan(500); + }); +}); diff --git a/frontend/src/__tests__/namAmpWrapperAndDriveVoiceLayout.test.ts b/frontend/src/__tests__/namAmpWrapperAndDriveVoiceLayout.test.ts new file mode 100644 index 0000000..36fecab --- /dev/null +++ b/frontend/src/__tests__/namAmpWrapperAndDriveVoiceLayout.test.ts @@ -0,0 +1,222 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + NAM_AMP_FACEPLATE_LAYOUT, + NAM_COMPRESSOR_FACEPLATE_LAYOUT, + NAM_EQ_BOOST_FACEPLATE_LAYOUT, + NAM_PRECISION_DRIVE_FACEPLATE_LAYOUT, + NAM_PRE_LOGICAL_SURFACE, + NAM_PRE_SIGNAL_LAYOUT, +} from "../components/NAMRackDesignPort"; + +const readSource = (relativePath: string) => + readFileSync(new URL(relativePath, import.meta.url), "utf8"); + +describe("NAM fixed-capture amp and separate EQ Boost / Drive faceplates", () => { + it("fits every existing amp wrapper parameter on the approved single control deck", () => { + const layout = NAM_AMP_FACEPLATE_LAYOUT; + const moduleWidth = 720; + const moduleHeight = 345; + const centres = [ + layout.powerX, + layout.inputX, + layout.boostX, + layout.voiceX, + layout.bassX, + layout.midX, + layout.trebleX, + layout.presenceX, + layout.mixX, + layout.outputX, + ].map((x) => x * moduleWidth / 100); + + const expectedCentres = [75, 120, 173.333333, 225, 291.666667, 360, 428.333333, 496.666667, 565, 633.333333]; + centres.forEach((centre, index) => { + expect(centre).toBeCloseTo(expectedCentres[index], 4); + }); + expect(layout.controlY * moduleHeight / 100).toBeCloseTo(745 / 3, 5); + expect(layout.labelY * moduleHeight / 100).toBeCloseTo(634 / 3, 5); + expect(layout.knobSize * moduleWidth / 100).toBeCloseTo(36, 8); + expect(layout.knobHitSize * moduleWidth / 100).toBeCloseTo(44, 8); + expect(layout.toggleSize * moduleWidth / 100).toBeCloseTo(22, 8); + expect(layout.toggleHitSize * moduleWidth / 100).toBeCloseTo(30, 8); + expect(layout.ledY * moduleHeight / 100).toBeCloseTo(683 / 3, 5); + expect(layout.ledSize * moduleWidth / 100).toBeCloseTo(12, 8); + expect(centres[0] - 15).toBeGreaterThanOrEqual(40); + expect(centres[centres.length - 1] + 22).toBeLessThanOrEqual(680); + + const source = readSource("../components/NAMRackDesignPort.tsx"); + const ampStage = source.slice( + source.indexOf("function AmpStage("), + source.indexOf("function CabSourceSelector("), + ); + for (const paramId of [ + "ampEnabled", + "ampBoost", + "ampVoice", + "ampGainDb", + "ampMix", + "ampOutputDb", + "bassDb", + "midDb", + "trebleDb", + "presenceDb", + ]) { + expect(ampStage).toContain(`\"${paramId}\"`); + } + expect(ampStage).toContain("amp-head-v5"); + expect(ampStage).toContain("CONTROLS.knobBlackPanel"); + expect(ampStage).toContain("CONTROLS.ledOnPanel"); + expect(ampStage).not.toContain("amp-gain-label-overlay"); + expect(ampStage).not.toContain("amp-row-divider"); + expect(ampStage).toContain('label: "BASS"'); + expect(ampStage).toContain('label: "MID"'); + expect(ampStage).toContain('label: "TREBLE"'); + expect(ampStage).toContain('label: "PRESENCE"'); + expect(ampStage).toContain('label: "GAIN"'); + expect(ampStage).not.toMatch(/label:\s*"(?:POST|MASTER)/); + + const namingSurfaces = [ + readSource("../services/NativeBridge.ts"), + readSource("../components/NAMRackPanel.tsx"), + readSource("../../../Source/AudioEngine.cpp"), + ]; + for (const sourceSurface of namingSurfaces) { + expect(sourceSurface).not.toMatch(/Post (?:Bass|Mid|Treble|Presence)/); + } + expect(namingSurfaces[0]).toContain('param("bassDb", "Bass"'); + expect(namingSurfaces[0]).toContain('param("midDb", "Mid"'); + expect(namingSurfaces[0]).toContain('param("trebleDb", "Treble"'); + expect(namingSurfaces[0]).toContain('param("presenceDb", "Presence"'); + }); + + it("keeps EQ Boost and Precision Drive separate inside one scrollbar-free row", () => { + const box = NAM_PRE_SIGNAL_LAYOUT.precisionDrive; + const layout = NAM_PRECISION_DRIVE_FACEPLATE_LAYOUT; + const xPx = (percentage: number) => percentage * box.w / 100; + const yPx = (percentage: number) => percentage * box.h / 100; + + expect(box).toMatchObject({ w: 120, h: 232 }); + expect(NAM_PRE_SIGNAL_LAYOUT.eqBoost).toMatchObject({ w: 156, h: 232 }); + expect(NAM_PRE_SIGNAL_LAYOUT.eqBoost.x + NAM_PRE_SIGNAL_LAYOUT.eqBoost.w) + .toBeLessThanOrEqual(box.x); + expect(NAM_PRE_SIGNAL_LAYOUT.distortion.x + NAM_PRE_SIGNAL_LAYOUT.distortion.w) + .toBe(NAM_PRE_LOGICAL_SURFACE.row.x + NAM_PRE_LOGICAL_SURFACE.row.w); + expect(NAM_PRE_LOGICAL_SURFACE.scaleReference).toEqual(NAM_PRE_LOGICAL_SURFACE.row); + expect(xPx(layout.gate.x)).toBeCloseTo(60, 8); + expect(yPx(layout.gate.y)).toBeCloseTo(76.56, 8); + expect(xPx(layout.gate.size)).toBeCloseTo(18, 8); + expect(xPx(layout.gate.hitSize)).toBeCloseTo(20, 8); + expect(NAM_EQ_BOOST_FACEPLATE_LAYOUT.bandYs).toHaveLength(8); + expect(NAM_EQ_BOOST_FACEPLATE_LAYOUT.title.y).toBe( + NAM_COMPRESSOR_FACEPLATE_LAYOUT.titleY, + ); + expect(NAM_EQ_BOOST_FACEPLATE_LAYOUT.title.x).toBe(50); + expect(NAM_EQ_BOOST_FACEPLATE_LAYOUT.led.x).toBe(50); + expect(NAM_EQ_BOOST_FACEPLATE_LAYOUT.foot.x).toBe(50); + expect(NAM_EQ_BOOST_FACEPLATE_LAYOUT.stateLabelY).toBe(82); + + // The wider fader consumes the former left-side void while preserving the + // old painted right boundary. Frequency copy still terminates three + // logical pixels before the rail, and the complete hit target remains + // clear of the filter controls. + const eqBoostWidth = NAM_PRE_SIGNAL_LAYOUT.eqBoost.w; + const eqBoostXPx = (percentage: number) => percentage * eqBoostWidth / 100; + const faderLeftPx = eqBoostXPx(NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderX) + - eqBoostXPx(NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderWidth) / 2; + const faderRightPx = eqBoostXPx(NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderX) + + eqBoostXPx(NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderWidth) / 2; + const faderWidthPx = faderRightPx - faderLeftPx; + const trackLeftPx = faderLeftPx + + faderWidthPx + * NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderTrackInsetPercent / 100; + const trackRightPx = faderRightPx + - faderWidthPx + * NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderTrackInsetPercent / 100; + const capTravelLeftPx = faderLeftPx + + faderWidthPx + * NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderCapMinPercent / 100; + const capTravelRightPx = capTravelLeftPx + + faderWidthPx + * NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderCapTravelPercent / 100; + const labelRightPx = NAM_EQ_BOOST_FACEPLATE_LAYOUT.bandLabelX + * eqBoostWidth / 100; + const filterHitLeftPx = eqBoostXPx(NAM_EQ_BOOST_FACEPLATE_LAYOUT.hpf.x) + - eqBoostXPx(NAM_EQ_BOOST_FACEPLATE_LAYOUT.filterHitSize) / 2; + + expect(eqBoostXPx(NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderX)).toBeCloseTo(64, 8); + expect(faderWidthPx).toBeCloseTo(72, 8); + expect(faderLeftPx).toBeCloseTo(28, 8); + expect(faderRightPx).toBeCloseTo(100, 8); + expect(trackLeftPx).toBeCloseTo(32.965517, 5); + expect(trackRightPx).toBeCloseTo(95.034483, 5); + expect(trackRightPx - trackLeftPx).toBeGreaterThan(50 * 1.2); + expect(capTravelLeftPx).toBeCloseTo(39, 5); + expect(capTravelRightPx).toBeCloseTo(89, 5); + expect(capTravelRightPx - capTravelLeftPx).toBeGreaterThan(44 * 1.1); + expect(trackLeftPx - labelRightPx).toBeCloseTo(2.965517, 5); + expect(filterHitLeftPx - faderRightPx).toBeCloseTo(10, 8); + expect(filterHitLeftPx - trackRightPx).toBeGreaterThan(14); + const rotatedCapWidthPx = 15; + expect(capTravelLeftPx - rotatedCapWidthPx / 2 - labelRightPx) + .toBeCloseTo(1.5, 5); + expect(filterHitLeftPx - (capTravelRightPx + rotatedCapWidthPx / 2)) + .toBeCloseTo(13.5, 5); + + const designCss = readSource("../components/NAMRackDesignPort.css"); + const bandLabelRule = designCss.match( + /\.combined-pre-eq-band-label\s*\{(?[\s\S]*?)\}/, + )?.groups?.body; + expect(bandLabelRule).toContain("transform: translate(-100%, -50%)"); + expect(bandLabelRule).toContain("text-align: right"); + const lastBandY = NAM_EQ_BOOST_FACEPLATE_LAYOUT.bandYs[ + NAM_EQ_BOOST_FACEPLATE_LAYOUT.bandYs.length - 1 + ] ?? 0; + expect( + (NAM_EQ_BOOST_FACEPLATE_LAYOUT.title.y - lastBandY) * + NAM_PRE_SIGNAL_LAYOUT.eqBoost.h / 100 - + NAM_EQ_BOOST_FACEPLATE_LAYOUT.faderHeight * + NAM_PRE_SIGNAL_LAYOUT.eqBoost.h / 200, + ).toBeGreaterThanOrEqual(10); + + const source = readSource("../components/NAMRackDesignPort.tsx"); + const profileSource = readSource("../utils/namInstrumentProfile.ts"); + const eqStart = source.indexOf("box={NAM_PRE_SIGNAL_LAYOUT.eqBoost}"); + const driveStart = source.indexOf("box={NAM_PRE_SIGNAL_LAYOUT.precisionDrive}"); + const driveEnd = source.indexOf("box={NAM_PRE_SIGNAL_LAYOUT.distortion}"); + const eqStage = source.slice(eqStart, driveStart); + const driveStage = source.slice(driveStart, driveEnd); + expect(eqStage).toContain('name="eq-boost"'); + expect(eqStage).toContain('title="EQ BOOST"'); + expect(driveStage).toContain('name="precision-drive"'); + expect(driveStage).toContain('title="PRECISION DRIVE"'); + expect(driveStage).not.toContain("precisionDriveVoice"); + expect(driveStage).not.toContain("OD808"); + expect(driveStage).toContain('paramId="precisionDriveGate"'); + expect(driveStage).toContain('paramId="precisionDriveEnabled"'); + expect(driveStage.match(/paramId="precisionDriveEnabled"/g)).toHaveLength(2); + expect(eqStage).toContain("paramId={band.paramId}"); + for (const paramId of [ + "preEq120Db", + "preEq250Db", + "preEq500Db", + "preEq1kDb", + "preEq2k5Db", + "preEq5kDb", + "preEq8kDb", + "preEq12kDb", + ]) { + expect(profileSource).toContain(`"${paramId}"`); + } + for (const paramId of [ + "preEqEnabled", + "preEqHPFHz", + "preEqLPFHz", + ]) { + expect(eqStage).toContain(`paramId="${paramId}"`); + } + }); + +}); diff --git a/frontend/src/__tests__/namApprovedSurfaceImplementationContract.test.ts b/frontend/src/__tests__/namApprovedSurfaceImplementationContract.test.ts new file mode 100644 index 0000000..49256c0 --- /dev/null +++ b/frontend/src/__tests__/namApprovedSurfaceImplementationContract.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; + +import { createNAMBootSchema } from "../components/BuiltInPluginPanel"; +import { + NAM_AMP_FACEPLATE_LAYOUT, + NAM_GRAPHIC_EQ_FACEPLATE_LAYOUT, + NAM_PRE_SIGNAL_LAYOUT, +} from "../components/NAMRackDesignPort"; +import { NAM_RACK_ADVANCED_CONTROL_IDS } from "../components/NAMRackMixer"; +import { + CURRENT_NAM_EFFECTS_DSP_VERSION, + CURRENT_NAM_REVERB_ENGINE_VERSION, + migrateLegacyNAMRackPresetDspState, +} from "../utils/namRackPresetTransactions"; + +const PRE_EQ_PARAM_IDS = [ + "preEqEnabled", + "preEq120Db", + "preEq250Db", + "preEq500Db", + "preEq1kDb", + "preEq2k5Db", + "preEq5kDb", + "preEq8kDb", + "preEq12kDb", + "preEqHPFHz", + "preEqLPFHz", +] as const; + +const PRE_EQ_BAND_IDS = PRE_EQ_PARAM_IDS.slice(1, 9); + +const DRIVE_PARAM_IDS = [ + "precisionDriveEnabled", + "precisionDriveDrive", + "precisionDriveVolumeDb", + "precisionDriveBright", + "precisionDriveAttack", + "precisionDriveGate", +] as const; + +const AMP_PARAM_IDS = [ + "ampEnabled", + "ampBoost", + "ampVoice", + "ampGainDb", + "bassDb", + "midDb", + "trebleDb", + "presenceDb", + "ampMix", + "ampOutputDb", +] as const; + +const POST_EQ_BAND_IDS = [ + "eq65Db", + "eq125Db", + "eq250Db", + "eq500Db", + "eq1kDb", + "eq2kDb", + "eq4kDb", + "eq8kDb", + "eq16kDb", +] as const; + +const POST_EQ_PARAM_IDS = [ + "eqEnabled", + ...POST_EQ_BAND_IDS, + "eqHPFHz", + "eqLevelDb", + "eqLPFHz", +] as const; + +describe("NAM Rack approved-surface implementation contract", () => { + it("exposes every approved Amp, post-EQ, Drive, and PRE-EQ parameter in the boot schema", () => { + const schema = createNAMBootSchema( + { chain: "track", trackId: "approved-surface-contract", fxIndex: 0 }, + "OpenStudio NAM Rack", + ); + const byId = new Map(schema.parameters.map((parameter) => [parameter.id, parameter])); + const required = [ + ...AMP_PARAM_IDS, + ...POST_EQ_PARAM_IDS, + ...DRIVE_PARAM_IDS, + ...PRE_EQ_PARAM_IDS, + ]; + + for (const paramId of required) expect(byId.has(paramId), paramId).toBe(true); + expect(byId.has("preEqLevelDb")).toBe(false); + + expect(byId.get("preEqEnabled")).toMatchObject({ + type: "toggle", + min: 0, + max: 1, + defaultValue: 0, + automatable: true, + }); + for (const paramId of PRE_EQ_BAND_IDS) { + expect(byId.get(paramId)).toMatchObject({ + type: "continuous", + min: -12, + max: 12, + defaultValue: 0, + unit: "dB", + automatable: true, + }); + } + expect(byId.get("preEqHPFHz")).toMatchObject({ + type: "continuous", + min: 0, + max: 180, + defaultValue: 0, + unit: "Hz", + automatable: true, + }); + expect(byId.get("preEqLPFHz")).toMatchObject({ + type: "continuous", + min: 3000, + max: 24000, + defaultValue: 24000, + unit: "Hz", + automatable: true, + }); + }); + + it("keeps EQ Boost and Precision Drive independently addressable as separate stages", () => { + expect(NAM_RACK_ADVANCED_CONTROL_IDS["pre-eq"]).toEqual(PRE_EQ_PARAM_IDS); + expect(NAM_RACK_ADVANCED_CONTROL_IDS["precision-drive"]).toEqual(DRIVE_PARAM_IDS); + expect(new Set(NAM_RACK_ADVANCED_CONTROL_IDS["precision-drive"]).size).toBe(6); + expect(NAM_RACK_ADVANCED_CONTROL_IDS["precision-drive"]).not.toContain("preEqLevelDb"); + }); + + it("preserves every remaining pedal size while centring the five-device PRE row", () => { + expect(NAM_PRE_SIGNAL_LAYOUT).toEqual({ + compressor: { x: 85, y: 42, w: 156, h: 232 }, + octaver: { x: 251, y: 42, w: 120, h: 232 }, + eqBoost: { x: 381, y: 42, w: 156, h: 232 }, + precisionDrive: { x: 547, y: 42, w: 120, h: 232 }, + distortion: { x: 677, y: 42, w: 156, h: 232 }, + }); + + expect(NAM_AMP_FACEPLATE_LAYOUT.controlY).toBeCloseTo(745 / 10.35, 6); + const ampCentres = [ + NAM_AMP_FACEPLATE_LAYOUT.powerX, + NAM_AMP_FACEPLATE_LAYOUT.inputX, + NAM_AMP_FACEPLATE_LAYOUT.boostX, + NAM_AMP_FACEPLATE_LAYOUT.voiceX, + NAM_AMP_FACEPLATE_LAYOUT.bassX, + NAM_AMP_FACEPLATE_LAYOUT.midX, + NAM_AMP_FACEPLATE_LAYOUT.trebleX, + NAM_AMP_FACEPLATE_LAYOUT.presenceX, + NAM_AMP_FACEPLATE_LAYOUT.mixX, + NAM_AMP_FACEPLATE_LAYOUT.outputX, + ]; + ampCentres.forEach((x, index) => { + expect(x).toBeCloseTo( + [225, 360, 520, 675, 875, 1080, 1285, 1490, 1695, 1900][index] / 21.6, + 8, + ); + }); + + NAM_GRAPHIC_EQ_FACEPLATE_LAYOUT.laneXs.forEach((x, index) => { + expect(x).toBeCloseTo( + [515, 656.25, 797.5, 938.75, 1080, 1221.25, 1362.5, 1503.75, 1645][index] / 21.6, + 8, + ); + }); + expect(NAM_GRAPHIC_EQ_FACEPLATE_LAYOUT.utility.levelX).toBeCloseTo(1870 / 21.6, 8); + expect(NAM_GRAPHIC_EQ_FACEPLATE_LAYOUT.utility.hpfX).toBeCloseTo(290 / 21.6, 8); + expect(NAM_GRAPHIC_EQ_FACEPLATE_LAYOUT.utility.lpfX).toBeCloseTo(1870 / 21.6, 8); + expect(NAM_GRAPHIC_EQ_FACEPLATE_LAYOUT.power.led.x).toBeCloseTo(400 / 21.6, 8); + expect( + NAM_GRAPHIC_EQ_FACEPLATE_LAYOUT.power.led.x + - NAM_GRAPHIC_EQ_FACEPLATE_LAYOUT.power.toggle.x, + ).toBeLessThan(5.5); + }); + + it("keeps EQ Boost state introduced in V16 while migrating its bands to V19", () => { + expect(CURRENT_NAM_EFFECTS_DSP_VERSION).toBe(19); + + const v15 = migrateLegacyNAMRackPresetDspState({ + values: { + preEqEnabled: 1, + preEq100Db: 6, + preEqHPFHz: 70, + preEqLPFHz: 11000, + }, + dspState: { + namEffectsDspVersion: 15, + reverbEngineVersion: CURRENT_NAM_REVERB_ENGINE_VERSION, + }, + }, { completePreset: true }) as { + values: Record; + dspState: Record; + }; + expect(v15.values).toMatchObject({ + preEqEnabled: 0, + preEq120Db: 0, + preEq250Db: 0, + preEq500Db: 0, + preEq1kDb: 0, + preEq2k5Db: 0, + preEq5kDb: 0, + preEq8kDb: 0, + preEq12kDb: 0, + preEqHPFHz: 0, + preEqLPFHz: 24000, + }); + expect(v15.dspState.namEffectsDspVersion).toBe(19); + expect(v15.values).not.toHaveProperty("preEq100Db"); + + const v16 = migrateLegacyNAMRackPresetDspState({ + values: { + preEqEnabled: 1, + preEq100Db: -2.5, + preEq200Db: -1.5, + preEq400Db: -0.5, + preEq800Db: 0.5, + preEq1k6Db: 1.5, + preEq3k2Db: 2.5, + preEq6k4Db: 3.5, + preEqHPFHz: 62, + preEqLPFHz: 12500, + }, + dspState: { + namEffectsDspVersion: 16, + reverbEngineVersion: CURRENT_NAM_REVERB_ENGINE_VERSION, + }, + }, { completePreset: true }) as { values: Record }; + expect(v16.values).toMatchObject({ + preEqEnabled: 1, + preEq120Db: -2.5, + preEq250Db: -1.5, + preEq500Db: -0.5, + preEq1kDb: 0.5, + preEq2k5Db: 1.5, + preEq5kDb: 2.5, + preEq8kDb: 3.5, + preEq12kDb: 0, + preEqHPFHz: 62, + preEqLPFHz: 12500, + }); + expect(v16.values).not.toHaveProperty("preEq6k4Db"); + }); +}); diff --git a/frontend/src/__tests__/namAssetIdentity.test.ts b/frontend/src/__tests__/namAssetIdentity.test.ts new file mode 100644 index 0000000..e5cbd6f --- /dev/null +++ b/frontend/src/__tests__/namAssetIdentity.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + findNAMAssetByIdentity, + NAMAssetIdentityKeys, + normalizeNAMAssetChecksum, + stableNAMAssetId, + withStableNAMAssetIdentity, +} from "../utils/namAssetIdentity"; + +const HASH = "a".repeat(64); + +describe("portable NAM asset identity", () => { + it("normalizes supported SHA-256 spellings and rejects malformed values", () => { + expect(normalizeNAMAssetChecksum(`SHA256:${HASH.toUpperCase()}`)).toBe(HASH); + expect(normalizeNAMAssetChecksum(`sha256=${HASH}`)).toBe(HASH); + expect(normalizeNAMAssetChecksum("abc")).toBe(""); + }); + + it("prefers file content identity over provider identifiers", () => { + expect(stableNAMAssetId({ checksum: HASH, modelId: 42 })).toBe(`sha256:${HASH}`); + expect(stableNAMAssetId({ sourceProvider: "TONE3000", modelId: 42 })).toBe("tone3000:model:42"); + }); + + it("finds a moved library record by checksum without relying on its path", () => { + const records = [ + { localPath: "D:/NAM/new-version.nam", checksum: "c".repeat(64), modelId: 42 }, + { localPath: "D:/NAM/moved.nam", checksum: HASH, modelId: 42 }, + { localPath: "D:/NAM/other.nam", checksum: "b".repeat(64), modelId: 99 }, + ]; + expect(findNAMAssetByIdentity(records, { + path: "C:/old/capture.nam", + checksum: HASH, + } as any)?.localPath).toBe("D:/NAM/moved.nam"); + }); + + it("persists a canonical asset id and non-duplicated lookup keys", () => { + const identified = withStableNAMAssetIdentity({ checksum: `sha256:${HASH}`, modelId: 7 }); + expect(identified.assetId).toBe(`sha256:${HASH}`); + expect(NAMAssetIdentityKeys(identified)).toEqual([`sha256:${HASH}`, "model:7"]); + }); +}); diff --git a/frontend/src/__tests__/namAssetRecovery.test.ts b/frontend/src/__tests__/namAssetRecovery.test.ts new file mode 100644 index 0000000..120084c --- /dev/null +++ b/frontend/src/__tests__/namAssetRecovery.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import type { BuiltInPluginSchema } from "../services/NativeBridge"; +import { resolveNAMRackMissingAssets } from "../utils/namAssetRecovery"; + +function schema(overrides: Partial = {}): BuiltInPluginSchema { + return { + schemaVersion: 1, + name: "OpenStudio NAM Rack", + category: "NAM", + chain: "track", + fxIndex: 0, + parameters: [ + { id: "pedalMix", label: "Pedal", type: "continuous", value: 1, min: 0, max: 1, defaultValue: 1 }, + { id: "ampEnabled", label: "Amp", type: "toggle", value: 1, min: 0, max: 1, defaultValue: 1 }, + { id: "cabEnabled", label: "Cab", type: "toggle", value: 1, min: 0, max: 1, defaultValue: 1 }, + ], + modelState: {}, + ...overrides, + }; +} + +describe("NAM current-rack asset recovery", () => { + it("distinguishes missing resources from intentionally empty slots", () => { + const result = resolveNAMRackMissingAssets(schema({ + modelState: { + pedalModelPath: "", + ampModelPath: "D:/Session/Missing Amp.nam", + cabIRPath: "", + hasPedalModel: false, + hasAmpModel: false, + hasCabIR: false, + cabIRState: "empty", + }, + })); + + expect(result).toEqual([ + expect.objectContaining({ slot: "amp", path: "D:/Session/Missing Amp.nam", bypassed: false }), + ]); + }); + + it("reports the native missing IR state and preserves bypass truth", () => { + const base = schema({ + parameters: [ + { id: "cabEnabled", label: "Cab", type: "toggle", value: 0, min: 0, max: 1, defaultValue: 1 }, + ], + modelState: { + cabIRPath: "D:/Session/Missing Cab.wav", + hasCabIR: false, + cabIRState: "missing", + }, + }); + + expect(resolveNAMRackMissingAssets(base)).toEqual([ + expect.objectContaining({ slot: "cab", bypassParamId: "cabEnabled", bypassed: true }), + ]); + }); + + it("does not treat a valid but bypassed resource as missing", () => { + const result = resolveNAMRackMissingAssets(schema({ + parameters: [ + { id: "ampEnabled", label: "Amp", type: "toggle", value: 0, min: 0, max: 1, defaultValue: 1 }, + ], + modelState: { ampModelPath: "D:/Session/Valid Amp.nam", hasAmpModel: true }, + })); + + expect(result).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/namCabPresentationFeedback.test.ts b/frontend/src/__tests__/namCabPresentationFeedback.test.ts new file mode 100644 index 0000000..dfcb435 --- /dev/null +++ b/frontend/src/__tests__/namCabPresentationFeedback.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { resolveNAMRackCabPresentation } from "../components/NAMCabPresentation"; + +describe("NAM Rack cabinet presentation feedback", () => { + it("makes a retained external IR's bypass state explicit for an embedded cab", () => { + const presentation = resolveNAMRackCabPresentation({ + hasAmpCapture: true, + hasCabIR: true, + embeddedCabCapture: true, + }); + + expect(presentation.mode).toBe("embedded"); + expect(presentation.hasRetainedExternalIR).toBe(true); + expect(presentation.status).toContain("retained external IR is bypassed"); + }); + + it("explains that the external stage is bypassed even when no IR is retained", () => { + const presentation = resolveNAMRackCabPresentation({ + hasAmpCapture: true, + hasCabIR: false, + embeddedCabCapture: true, + }); + + expect(presentation.status).toContain("external Cab/IR stage is bypassed"); + }); +}); diff --git a/frontend/src/__tests__/namCabRoomConsoleLayout.test.ts b/frontend/src/__tests__/namCabRoomConsoleLayout.test.ts new file mode 100644 index 0000000..dfad600 --- /dev/null +++ b/frontend/src/__tests__/namCabRoomConsoleLayout.test.ts @@ -0,0 +1,95 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + computePremiumStagePlacement, + NAM_CAB_ROOM_CONSOLE_LAYOUT, + NAM_PANEL_ROTARY_VARIANT_PX, +} from "../components/NAMRackDesignPort"; + +describe("NAM Rack integrated Cab and Room console", () => { + it("fits the approved single IR shaper and Room console at every stage size", () => { + const { group, console } = NAM_CAB_ROOM_CONSOLE_LAYOUT; + + expect(console).toEqual(group); + expect(console.h).toBeGreaterThan(390); + expect(console.w / console.h).toBeGreaterThan(1.6); + expect(console.w / console.h).toBeLessThan(1.7); + + for (const viewport of [ + { width: 720, height: 410 }, + { width: 1010, height: 520 }, + { width: 1530, height: 775 }, + ]) { + for (const size of [80, 100, 140, 180, 220]) { + const placement = computePremiumStagePlacement(viewport, group, size); + for (const box of [console]) { + const left = placement.left + box.x * placement.scale; + const right = left + box.w * placement.scale; + const top = placement.top + box.y * placement.scale; + const bottom = top + box.h * placement.scale; + expect(left).toBeGreaterThanOrEqual(0); + expect(right).toBeLessThanOrEqual(viewport.width); + expect(top).toBeGreaterThanOrEqual(0); + expect(bottom).toBeLessThanOrEqual(viewport.height); + } + } + } + }); + + it("keeps seven evenly separated primary controls above a full-width Room bay", () => { + const layout = NAM_CAB_ROOM_CONSOLE_LAYOUT; + const cabRotaryRadius = NAM_PANEL_ROTARY_VARIANT_PX.cabPanel / 2; + expect(layout.topKnobXs).toHaveLength(7); + expect(NAM_PANEL_ROTARY_VARIANT_PX.cabPanel).toBe(42); + expect(layout.topKnobXs[0] / 100 * layout.console.w - cabRotaryRadius).toBeGreaterThan(0); + expect( + layout.topKnobXs[layout.topKnobXs.length - 1] / 100 * layout.console.w + cabRotaryRadius, + ).toBeLessThan(layout.console.w); + for (let index = 1; index < layout.topKnobXs.length; index += 1) { + expect(layout.topKnobXs[index] - layout.topKnobXs[index - 1]).toBeGreaterThan(12); + expect( + (layout.topKnobXs[index] - layout.topKnobXs[index - 1]) / 100 * layout.console.w, + ).toBeGreaterThan(NAM_PANEL_ROTARY_VARIANT_PX.cabPanel); + } + expect(layout.topKnobY).toBeLessThan(layout.utilityY); + expect(layout.utilityY).toBeLessThan(layout.roomBayTop); + }); + + it("binds the approved Room controls independently from the external IR lock", () => { + const source = readFileSync( + new URL("../components/NAMRackDesignPort.tsx", import.meta.url), + "utf8", + ); + const cabStageStart = source.indexOf("function CabStage("); + const cabStageEnd = source.indexOf("function EqStage()", cabStageStart); + const cabStage = source.slice(cabStageStart, cabStageEnd); + + expect(cabStage).toContain('className="cab-room-bay"'); + expect(cabStage).toContain(""); + expect(cabStage).toContain("body={BODIES.cabRoomIntegrated}"); + expect(cabStage).not.toContain('name="cabinet"'); + expect(cabStage).toContain('paramId="cabRoomAmount"'); + expect(cabStage).toContain('paramId="cabRoomWidth"'); + expect(cabStage).toContain('paramId="cabPan"'); + expect(cabStage.match(/panelRotaryVariant="cabPanel"/g)).toHaveLength(7); + expect(cabStage).toContain("POST-CAB AMBIENCE"); + expect(cabStage).toContain('roomWaitingForCabSource ? "No cab source"'); + expect(cabStage).toContain('data-status={roomWaitingForCabSource ? "no-source" : "ready"}'); + expect(cabStage).toContain("roomEnabled && !cabRoomInputSourceAvailable"); + expect(cabStage).toContain(""); + expect(cabStage.indexOf('className="cab-room-bay"')) + .toBeGreaterThan(cabStage.indexOf("")); + expect(cabStage).not.toContain(" { + expect(NAM_PANEL_ROTARY_VARIANT_PX.roomHero).toBeGreaterThan(60); + + const source = readFileSync( + new URL("../components/NAMRackDesignPort.tsx", import.meta.url), + "utf8", + ); + expect(source.match(/panelRotaryVariant="roomHero"/g)).toHaveLength(2); + }); +}); diff --git a/frontend/src/__tests__/namCabinetSpace.test.ts b/frontend/src/__tests__/namCabinetSpace.test.ts new file mode 100644 index 0000000..c86d04f --- /dev/null +++ b/frontend/src/__tests__/namCabinetSpace.test.ts @@ -0,0 +1,195 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { resolveNAMRackCabinetSpaceActivity } from "../services/NativeBridge"; +import { + NAM_RACK_ADVANCED_CONTROL_IDS, + NAM_RACK_CAB_ADVANCED_CONTROL_GROUPS, + NAM_RACK_CABINET_SPACE_PARAM_IDS, + isNAMRackCabinetSpaceParamId, + namRackAdvancedStageForCompactModule, +} from "../components/NAMRackMixer"; +import { migrateLegacyNAMRackPresetDspState } from "../utils/namRackPresetTransactions"; + +describe("NAM Rack Cabinet Space controls", () => { + it("keeps the room and doubler in their own stable advanced-control group", () => { + expect(NAM_RACK_CABINET_SPACE_PARAM_IDS).toEqual([ + "cabRoomEnabled", + "cabRoomAmount", + "cabRoomWidth", + "cabDoublerEnabled", + "cabDoublerMix", + "cabDoublerDelayMs", + "cabDoublerSpread", + ]); + expect(NAM_RACK_CAB_ADVANCED_CONTROL_GROUPS[1]).toEqual({ + id: "room", + label: "Room", + paramIds: ["cabRoomEnabled", "cabRoomAmount", "cabRoomWidth"], + }); + expect(NAM_RACK_CAB_ADVANCED_CONTROL_GROUPS[2]).toEqual({ + id: "doubler", + label: "Doubler", + paramIds: ["cabDoublerEnabled", "cabDoublerMix", "cabDoublerDelayMs", "cabDoublerSpread"], + }); + expect(NAM_RACK_ADVANCED_CONTROL_IDS.cab).toEqual([...NAM_RACK_CAB_ADVANCED_CONTROL_GROUPS[0].paramIds]); + expect(NAM_RACK_ADVANCED_CONTROL_IDS.room).toEqual([...NAM_RACK_CAB_ADVANCED_CONTROL_GROUPS[1].paramIds]); + expect(NAM_RACK_ADVANCED_CONTROL_IDS.doubler).toEqual([...NAM_RACK_CAB_ADVANCED_CONTROL_GROUPS[2].paramIds]); + expect(isNAMRackCabinetSpaceParamId("cabRoomAmount")).toBe(true); + expect(isNAMRackCabinetSpaceParamId("cabRoomSend")).toBe(false); + }); + + it("uses the locked defaults in both mock schema and portable preset defaults", () => { + const bridgeSource = readFileSync(new URL("../services/NativeBridge.ts", import.meta.url), "utf8"); + const panelSource = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + + for (const expected of [ + 'param("cabRoomEnabled", "Room", 0, 0, 1, "", "cabinetSpace", "toggle")', + 'param("cabRoomAmount", "Room Amount", 0.22, 0, 1', + 'param("cabRoomWidth", "Room Width", 0.65, 0, 1', + 'param("cabDoublerEnabled", "Doubler", 0, 0, 1, "", "cabinetSpace", "toggle")', + 'param("cabDoublerMix", "Doubler Mix", 0.12, 0, 1', + 'param("cabDoublerDelayMs", "Doubler Delay", 4.5, 3, 20, "ms", "cabinetSpace")', + 'param("cabDoublerSpread", "Doubler Spread", 0.65, 0, 1', + ]) { + expect(bridgeSource).toContain(expected); + } + expect(panelSource).toContain("cabRoomEnabled: 0"); + expect(panelSource).toContain("cabRoomAmount: 0.22"); + expect(panelSource).toContain("cabRoomWidth: 0.65"); + expect(panelSource).toContain("cabDoublerEnabled: 0"); + expect(panelSource).toContain("cabDoublerMix: 0.12"); + expect(panelSource).toContain("cabDoublerDelayMs: 4.5"); + expect(panelSource).toContain("cabDoublerSpread: 0.65"); + expect(panelSource).toContain("paramGroups: [NAM_RACK_CAB_ADVANCED_CONTROL_GROUPS[0]]"); + expect(panelSource).toContain("Room has its own power switch"); + expect(panelSource).toContain("Doubler has its own power switch"); + expect(panelSource).toContain("before EQ, Modulation, Delay, and Reverb"); + }); + + it("fills deterministic cabinet-space values without persisting routing topology", () => { + const migrated = migrateLegacyNAMRackPresetDspState({ + values: {}, + dspState: { namEffectsDspVersion: 5, reverbEngineVersion: 3 }, + }, { completePreset: true }) as { values: Record }; + + expect(migrated.values).toMatchObject({ + cabRoomEnabled: 0, + cabRoomAmount: 0.22, + cabRoomWidth: 0.65, + cabDoublerEnabled: 0, + cabDoublerMix: 0.12, + cabDoublerDelayMs: 4.5, + cabDoublerSpread: 0.65, + }); + expect(migrated.values).not.toHaveProperty("inputMode"); + }); + + it("reports Room and Doubler activity independently from the external Cab/IR power", () => { + expect(resolveNAMRackCabinetSpaceActivity(0, 0)).toEqual({ + active: false, + roomActive: false, + doublerActive: false, + label: "", + }); + expect(resolveNAMRackCabinetSpaceActivity(1, 0)).toEqual({ + active: true, + roomActive: true, + doublerActive: false, + label: "Room", + }); + expect(resolveNAMRackCabinetSpaceActivity(0, 1)).toEqual({ + active: true, + roomActive: false, + doublerActive: true, + label: "Doubler", + }); + expect(resolveNAMRackCabinetSpaceActivity(1, 1)).toEqual({ + active: true, + roomActive: true, + doublerActive: true, + label: "Room + Doubler", + }); + expect(resolveNAMRackCabinetSpaceActivity(Number.NaN, undefined)).toEqual({ + active: false, + roomActive: false, + doublerActive: false, + label: "", + }); + expect(resolveNAMRackCabinetSpaceActivity(0.49, 0.49)).toEqual({ + active: false, + roomActive: false, + doublerActive: false, + label: "", + }); + }); + + it("keeps compact Cab/IR, Room, and Doubler independently powered without duplicate advanced editors", () => { + const panelSource = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + + expect(panelSource).toContain("const cabinetStageActive = embeddedCabCapture || cabActive || cabinetSpaceAudible"); + expect(panelSource).toContain('id: "room"'); + expect(panelSource).toContain('label: "Room"'); + expect(panelSource).toContain('id: "doubler"'); + expect(panelSource).toContain('label: "Doubler"'); + expect(panelSource).toContain("const toggleRoom = () => toggleEffectPower(cabRoomEnabledParam, roomActive)"); + expect(panelSource).toContain("const toggleDoubler = () => toggleEffectPower(cabDoublerEnabledParam, doublerActive)"); + expect(panelSource).not.toContain("toggleCabinetSpacePower"); + expect(panelSource).not.toContain("cabinetSpaceBusy"); + expect(panelSource).toContain('onToggle: !cabEnabledParam || !cabPresentation.canToggleExternalCab ? undefined : toggleCabPower'); + expect(namRackAdvancedStageForCompactModule("cab-ir")).toBeNull(); + expect(namRackAdvancedStageForCompactModule("room")).toBeNull(); + expect(namRackAdvancedStageForCompactModule("doubler")).toBeNull(); + }); + + it("preserves an enabled Room while reporting that no speaker-voiced cab source is available", () => { + const panelSource = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + + expect(panelSource).toContain('booleanFromRecord(rackDiagnostics, "cabRoomInputSourceAvailable")'); + expect(panelSource).toContain("?? (cabActive || (ampActive && embeddedCabCapture))"); + expect(panelSource).toContain("const roomWaitingForCabSource = roomActive && !cabRoomInputSourceAvailable"); + expect(panelSource).toContain('roomWaitingForCabSource ? "No cab source"'); + expect(panelSource).toContain("enabled: roomActive"); + expect(panelSource).not.toContain("onParamChange(cabRoomEnabledParam, 0)"); + }); + + it("keeps Doubler visible globally and pauses it in Stereo without rewriting its saved settings", () => { + const panelSource = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + + expect(panelSource).toContain("const doublerAudible = doublerActive && !stereoInputActive"); + expect(panelSource).toContain('className="nam-neural-global-knob nam-neural-global-doubler"'); + expect(panelSource).toContain("the DAW route is stereo"); + expect(panelSource).toContain("disabled={stereoInputActive}"); + expect(panelSource).toContain('paramById(params, "cabDoublerDelayMs")'); + expect(panelSource).toContain("Mix, Delay, and Spread are preserved"); + expect(panelSource).not.toContain("onParamChange(cabDoublerEnabledParam, 0)"); + }); + + it("binds an uncluttered 3-20 ms Delay control and explicit readout on the current Doubler asset", () => { + const designSource = readFileSync(new URL("../components/NAMRackDesignPort.tsx", import.meta.url), "utf8"); + const utilityStart = designSource.indexOf("function PremiumHeaderUtility("); + const utilityEnd = designSource.indexOf("function BoundDistortionModeDisplay", utilityStart); + const doublerUtility = designSource.slice(utilityStart, utilityEnd); + + expect(doublerUtility).toContain('useBoundDesignParam("cabDoublerDelayMs")'); + expect(doublerUtility).toContain('data-utility-rotary="delay"'); + expect(doublerUtility).toContain('paramId="cabDoublerDelayMs"'); + expect(doublerUtility).toContain("Delay"); + expect(doublerUtility).toContain("{doublerDelayLabel}"); + expect(doublerUtility).toContain('"4.5 ms"'); + }); + + it("keeps legacy cabRoomSend named Bloom rather than misrepresenting it as the new room", () => { + const design = readFileSync(new URL("../components/NAMRackDesignPort.tsx", import.meta.url), "utf8"); + const paramIndex = design.indexOf('paramId="cabRoomSend"'); + const controlStart = design.lastIndexOf("", paramIndex); + const control = design.slice(controlStart, controlEnd + 2); + + expect(paramIndex).toBeGreaterThan(-1); + expect(controlStart).toBeGreaterThan(-1); + expect(control).toContain('paramId="cabRoomSend"'); + expect(control).toContain('labelText="LOW BLOOM"'); + expect(control).not.toContain('labelText="ROOM"'); + }); +}); diff --git a/frontend/src/__tests__/namCaptureActivation.test.ts b/frontend/src/__tests__/namCaptureActivation.test.ts new file mode 100644 index 0000000..958852c --- /dev/null +++ b/frontend/src/__tests__/namCaptureActivation.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import { + expectedNAMEffectiveCabEnabled, + inspectNAMCaptureActivation, + inspectNAMCaptureSchemaActivation, + namCaptureUsePhaseLabel, + namCaptureStateFromSchema, +} from "../utils/namCaptureActivation"; + +describe("NAM capture activation readback", () => { + it("derives the effective Cab invariant from request and embedded topology", () => { + expect(expectedNAMEffectiveCabEnabled(true, false)).toBe(true); + expect(expectedNAMEffectiveCabEnabled(false, false)).toBe(false); + expect(expectedNAMEffectiveCabEnabled(true, true)).toBe(false); + expect(expectedNAMEffectiveCabEnabled(false, true)).toBe(false); + }); + + it("exposes the complete professional Use lifecycle for browser QA", () => { + expect(["downloading", "preparing", "activating", "success", "error"].map((phase) => + namCaptureUsePhaseLabel(phase as Parameters[0]), + )).toEqual([ + "Downloading...", + "Installing / Preparing...", + "Activating...", + "Activated", + "Retry Use Capture", + ]); + }); + + it("does not accept a matching path when the amp graph failed to load", () => { + const result = inspectNAMCaptureActivation({ + modelState: { + ampModelPath: "C:/OpenStudio/NAM/library/tone-42/Crunch.nam", + hasAmpModel: false, + lastLoadError: "Unsupported NAM architecture", + }, + values: { auditionSource: 0 }, + uiState: { namActivePreview: null }, + }, "amp", "c:\\openstudio\\nam\\library\\tone-42\\crunch.nam", { + requirePreviewCleared: true, + }); + + expect(result.verified).toBe(false); + expect(result.pathMatches).toBe(true); + expect(result.resourceLoaded).toBe(false); + expect(result.reason).toBe("Unsupported NAM architecture"); + }); + + it("requires the chosen capture, loaded graph, live input, and cleared preview marker", () => { + expect(inspectNAMCaptureActivation({ + modelState: { + ampModelPath: "C:/NAM/Chosen.nam", + hasAmpModel: true, + cabRequestedEnabled: true, + lastLoadError: "", + }, + values: { auditionSource: 0, ampEnabled: 1, ampMix: 1, cabEnabled: 1 }, + uiState: { namActivePreview: null }, + }, "amp", "C:/NAM/Chosen.nam", { + requirePreviewCleared: true, + expectedCabRequestedEnabled: true, + }).verified).toBe(true); + + expect(inspectNAMCaptureActivation({ + modelState: { + ampModelPath: "C:/NAM/Chosen.nam", + hasAmpModel: true, + cabRequestedEnabled: true, + }, + values: { auditionSource: 1, ampEnabled: 1, ampMix: 1, cabEnabled: 1 }, + uiState: { namActivePreview: { slot: "amp" } }, + }, "amp", "C:/NAM/Chosen.nam", { + requirePreviewCleared: true, + })).toMatchObject({ + verified: false, + liveSource: false, + previewCleared: false, + }); + }); + + it("validates the exact schema accepted by the rack view before leaving the library", () => { + const emptySchema = { + parameters: [ + { id: "auditionSource", value: 0 }, + { id: "ampEnabled", value: 1 }, + { id: "ampMix", value: 1 }, + { id: "cabEnabled", value: 0 }, + ], + modelState: { + ampModelPath: "", + hasAmpModel: false, + cabRequestedEnabled: false, + }, + uiState: { namActivePreview: null }, + }; + const acceptedSchema = { + ...emptySchema, + parameters: emptySchema.parameters.map((parameter) => + parameter.id === "cabEnabled" ? { ...parameter, value: 1 } : parameter, + ), + modelState: { + ampModelPath: "C:/NAM/Installed/Chosen.nam", + hasAmpModel: true, + ampIncludesCab: false, + cabRequestedEnabled: true, + lastLoadError: "", + }, + }; + + expect(namCaptureStateFromSchema(acceptedSchema)?.values).toMatchObject({ + auditionSource: 0, + ampEnabled: 1, + ampMix: 1, + cabEnabled: 1, + }); + expect(inspectNAMCaptureSchemaActivation( + emptySchema, + "amp", + "C:/NAM/Installed/Chosen.nam", + { + requireLiveSource: true, + requirePreviewCleared: true, + expectedCabRequestedEnabled: true, + }, + ).verified).toBe(false); + expect(inspectNAMCaptureSchemaActivation( + acceptedSchema, + "amp", + "c:\\nam\\installed\\chosen.nam", + { + requireLiveSource: true, + requirePreviewCleared: true, + expectedCabRequestedEnabled: true, + }, + )).toMatchObject({ + verified: true, + pathMatches: true, + resourceLoaded: true, + liveSource: true, + previewCleared: true, + }); + }); + + it("requires an activated amp capture to have power, wet mix, and preserved Cab intent", () => { + const baseState = { + modelState: { + ampModelPath: "C:/NAM/Chosen.nam", + hasAmpModel: true, + cabRequestedEnabled: true, + }, + values: { + auditionSource: 0, + cabEnabled: 1, + ampEnabled: 1, + ampMix: 1, + }, + }; + + expect(inspectNAMCaptureActivation({ + ...baseState, + values: { ...baseState.values, ampEnabled: 0 }, + }, "amp", "C:/NAM/Chosen.nam", { + expectedCabRequestedEnabled: true, + }).reason).toBe("The amp capture loaded but Amp Power remained off."); + + expect(inspectNAMCaptureActivation({ + ...baseState, + values: { ...baseState.values, ampMix: 0 }, + }, "amp", "C:/NAM/Chosen.nam", { + expectedCabRequestedEnabled: true, + }).reason).toBe("The amp capture loaded but Capture Mix remained fully dry."); + + expect(inspectNAMCaptureActivation({ + ...baseState, + modelState: { ...baseState.modelState, cabRequestedEnabled: false }, + }, "amp", "C:/NAM/Chosen.nam", { + expectedCabRequestedEnabled: true, + }).reason).toBe("The rack did not preserve the requested external-cabinet preference."); + + expect(inspectNAMCaptureActivation({ + ...baseState, + modelState: { ...baseState.modelState, ampIncludesCab: true }, + values: { ...baseState.values, cabEnabled: 1 }, + }, "amp", "C:/NAM/Chosen.nam", { + expectedCabRequestedEnabled: true, + }).reason).toBe("The effective Cab/IR state did not match the requested preference and amp topology."); + + expect(inspectNAMCaptureActivation({ + ...baseState, + values: { ...baseState.values, cabEnabled: 0 }, + }, "amp", "C:/NAM/Chosen.nam", { + expectedCabRequestedEnabled: true, + }).reason).toBe("The effective Cab/IR state did not match the requested preference and amp topology."); + + expect(inspectNAMCaptureActivation({ + ...baseState, + modelState: { ...baseState.modelState, cabRequestedEnabled: false }, + values: { ...baseState.values, cabEnabled: 1 }, + }, "amp", "C:/NAM/Chosen.nam", { + expectedCabRequestedEnabled: false, + }).reason).toBe("The effective Cab/IR state did not match the requested preference and amp topology."); + + expect(inspectNAMCaptureActivation({ + ...baseState, + modelState: { ...baseState.modelState, ampIncludesCab: true }, + values: { ...baseState.values, cabEnabled: 0 }, + }, "amp", "C:/NAM/Chosen.nam", { + expectedCabRequestedEnabled: true, + })).toMatchObject({ + verified: true, + expectedEffectiveCabEnabled: false, + cabTopologySafe: true, + }); + }); + +}); diff --git a/frontend/src/__tests__/namCaptureUseFlow.test.ts b/frontend/src/__tests__/namCaptureUseFlow.test.ts new file mode 100644 index 0000000..a854f53 --- /dev/null +++ b/frontend/src/__tests__/namCaptureUseFlow.test.ts @@ -0,0 +1,133 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const explorerSource = readFileSync( + new URL("../components/NAMExplorer.tsx", import.meta.url), + "utf8", +).replace(/\r\n?/g, "\n"); +const useFlowStart = explorerSource.indexOf("const useSourceFlowSelection = async"); +const useFlowEnd = explorerSource.indexOf("const applySourceFlowDesignTab", useFlowStart); +const useFlowSource = explorerSource.slice(useFlowStart, useFlowEnd); +const auditionPublishStart = explorerSource.indexOf("const loadRecordForAudition = async"); +const auditionPublishEnd = explorerSource.indexOf("const loadRecordIntoCabIR = async", auditionPublishStart); +const auditionPublishSource = explorerSource.slice(auditionPublishStart, auditionPublishEnd); +const sourceFlowTargetsStart = explorerSource.indexOf("const sourceFlowTargetCards ="); +const sourceFlowTargetsEnd = explorerSource.indexOf("const sourceCategoryLabel =", sourceFlowTargetsStart); +const sourceFlowTargetsSource = explorerSource.slice(sourceFlowTargetsStart, sourceFlowTargetsEnd); +const sourceFlowHeaderStart = explorerSource.indexOf('
'); +const sourceFlowHeaderEnd = explorerSource.indexOf('
", monoInputStart); + const monoInputMarkup = monoHtml.slice(monoInputStart, monoInputEnd); + const monoOutputStart = monoHtml.indexOf('data-qa="nam-output-peak-meter"'); + const monoOutputEnd = monoHtml.indexOf("
", monoOutputStart); + const monoOutputMarkup = monoHtml.slice(monoOutputStart, monoOutputEnd); + + expect(monoInputMarkup).toContain('data-channel-count="1"'); + expect(monoInputMarkup).toContain('data-meter-channel="mono"'); + expect(monoInputMarkup).not.toContain('data-meter-channel="right"'); + expect(monoInputMarkup).toContain('aria-label="Pre-trim input level: mono peak -6.0 dBFS"'); + expect(monoOutputMarkup).toContain('data-channel-count="2"'); + expect(monoOutputMarkup).toContain('data-meter-channel="left"'); + expect(monoOutputMarkup).toContain('data-meter-channel="right"'); + expect(monoOutputMarkup).toContain('aria-label="Output level: left -3.0 dBFS, right -15.0 dBFS"'); + + const stereoHtml = renderHeaderUtility( + defaultUtilityControls, + headerParams, + { + inputLevelDb: -6, + outputLevelDb: -3, + inputLeftLevelDb: -6, + inputRightLevelDb: -24, + outputLeftLevelDb: -3, + outputRightLevelDb: -15, + inputChannelCount: 2, + }, + ); + const stereoInputStart = stereoHtml.indexOf('data-qa="nam-input-peak-meter"'); + const stereoInputEnd = stereoHtml.indexOf("
", stereoInputStart); + const stereoInputMarkup = stereoHtml.slice(stereoInputStart, stereoInputEnd); + + expect(stereoInputMarkup).toContain('data-channel-count="2"'); + expect(stereoInputMarkup).toContain('data-meter-channel="left"'); + expect(stereoInputMarkup).toContain('data-meter-channel="right"'); + expect(stereoInputMarkup).toContain('aria-label="Pre-trim input level: left -6.0 dBFS, right -24.0 dBFS"'); + }); + + it("renders all responsive header regions for browser geometry coverage", () => { + const html = renderHeaderUtility(); + + expect(html).toContain('data-qa="nam-input-control-bay"'); + expect(html).toContain('class="preset-area '); + expect(html).toContain('class="preset-console '); + expect(html).toContain('data-qa="nam-header-utility"'); + expect(html).toContain('data-qa="nam-output-control-bay"'); + expect(html).toContain('class="premium-oversampling-selector"'); + expect(html).toContain('data-qa="nam-oversampling-4x"'); + }); +}); diff --git a/frontend/src/__tests__/namInputMode.test.ts b/frontend/src/__tests__/namInputMode.test.ts new file mode 100644 index 0000000..1bc808d --- /dev/null +++ b/frontend/src/__tests__/namInputMode.test.ts @@ -0,0 +1,148 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + nativeBridge, + projectNAMRackSchemaForUI, + resolveNAMRackOctaverPresentation, + type BuiltInPluginSchema, +} from "../services/NativeBridge"; +import { NAM_RACK_ADVANCED_CONTROL_IDS } from "../components/NAMRackMixer"; + +function schemaForNAMEffectsVersion(version: number | undefined): BuiltInPluginSchema { + return { + schemaVersion: 1, + name: "OpenStudio NAM Rack", + category: "Built-in", + chain: "track", + fxIndex: 0, + parameters: [ + { + id: "inputMode", + label: "Input Mode", + type: "enum", + value: 2, + min: 0, + max: 2, + defaultValue: 0, + enumOptions: [ + { value: 0, label: "Mono" }, + { value: 2, label: "Stereo" }, + ], + }, + { + id: "auditionSource", + label: "Demo Source", + type: "toggle", + value: 1, + min: 0, + max: 1, + defaultValue: 0, + }, + { + id: "octaverEnabled", + label: "Legacy Octaver", + type: "toggle", + value: 0, + min: 0, + max: 1, + defaultValue: 0, + }, + ], + modelState: version === undefined ? {} : { namEffectsDspVersion: version }, + }; +} + +describe("NAM Rack automatic input routing contract", () => { + it("removes the retired input-mode and audition parameters from every UI schema", () => { + for (const version of [undefined, 1, 10, 11]) { + const projected = projectNAMRackSchemaForUI(schemaForNAMEffectsVersion(version)); + expect(projected.parameters.map(({ id }) => id)).toEqual(["octaverEnabled"]); + if (version !== undefined) { + expect(projected.parameters[0].label).toBe("Stereo Poly Octaver"); + } + } + }); + + it("keeps routing topology out of the dev schema and Device Controls", () => { + const bridgeSource = readFileSync(new URL("../services/NativeBridge.ts", import.meta.url), "utf8"); + const panelSource = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + + expect(bridgeSource).not.toContain('param("inputMode"'); + expect(bridgeSource).not.toContain("inputMode: NAM_RACK_INPUT_MODE_OPTIONS"); + expect(bridgeSource).toContain('parameter.id !== "inputMode"'); + expect(NAM_RACK_ADVANCED_CONTROL_IDS.input).toEqual(["inputTrimDb"]); + expect(panelSource).not.toContain('paramById(params, "inputMode")'); + expect(panelSource).not.toContain("NAM_RACK_INPUT_MODE_OPTIONS"); + expect(panelSource).not.toContain("setPendingInputModeWrite"); + expect(panelSource).not.toContain('aria-label="NAM processing mode"'); + }); + + it("rejects retired dev parameter writes and prunes legacy state at set, persistence, and readback", async () => { + const scope = globalThis as any; + const previousWindow = Object.getOwnPropertyDescriptor(scope, "window"); + Object.defineProperty(scope, "window", { + configurable: true, + writable: true, + value: { + location: { search: "?mockPlugin=nam" }, + setTimeout, + clearTimeout, + }, + }); + + try { + const address = { trackId: "retired-routing-test", chain: "track" as const, fxIndex: 991 }; + expect(await nativeBridge.setBuiltInPluginState(address, JSON.stringify({ + values: { inputMode: 2, ampMix: 0.73 }, + uiState: { + namPresetBaseline: { values: { inputMode: 0, delayMix: 0.24 } }, + namRackCompare: { + snapshots: { + A: { values: { inputMode: 2, reverbMix: 0.31 } }, + }, + }, + }, + dspState: { namEffectsDspVersion: 10, reverbEngineVersion: 5 }, + }))).toBe(true); + expect(await nativeBridge.setBuiltInPluginParam(address, "inputMode", 0)).toBe(false); + + const readback = await nativeBridge.getBuiltInPluginState(address); + expect(JSON.stringify(readback)).not.toContain('"inputMode"'); + expect(readback.values.ampMix).toBe(0.73); + expect(readback.uiState.namPresetBaseline.values.delayMix).toBe(0.24); + expect(readback.uiState.namRackCompare.snapshots.A.values.reverbMix).toBe(0.31); + expect(readback.dspState.namEffectsDspVersion).toBe(19); + } finally { + if (previousWindow) { + Object.defineProperty(scope, "window", previousWindow); + } else { + delete scope.window; + } + } + }); + + it("uses native effective routing diagnostics only for behavior that depends on topology", () => { + const bridgeSource = readFileSync(new URL("../services/NativeBridge.ts", import.meta.url), "utf8"); + const panelSource = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + + expect(bridgeSource).toContain("inputRoutingAutomatic?: boolean"); + expect(bridgeSource).toContain("automaticInputRoutingMode?: number"); + expect(bridgeSource).toContain("effectiveInputRoutingMode?: number"); + expect(panelSource).toContain('numberFromRecord(rackLiveDiagnostics, "effectiveInputRoutingMode")'); + expect(panelSource).toContain('numberFromRecord(rackLiveDiagnostics, "activeInputRoutingMode")'); + expect(panelSource).toContain('numberFromRecord(rackLiveDiagnostics, "automaticInputRoutingMode")'); + expect(panelSource).toContain("const stereoInputActive = effectiveInputRoutingMode >= 1.5"); + expect(panelSource).toContain("the DAW route is stereo"); + }); + + it("always presents the single current stereo-poly octaver", () => { + for (const version of [1, 2, 3, 4, 5, 6, 10, 11, undefined]) { + expect(resolveNAMRackOctaverPresentation(version)).toMatchObject({ + label: "Stereo Poly Octaver", + captionPrefix: "Polyphonic stereo", + stereoPolyphonic: true, + }); + } + }); +}); diff --git a/frontend/src/__tests__/namInstrumentProfile.test.ts b/frontend/src/__tests__/namInstrumentProfile.test.ts new file mode 100644 index 0000000..9a30871 --- /dev/null +++ b/frontend/src/__tests__/namInstrumentProfile.test.ts @@ -0,0 +1,315 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NAMExplorer } from "../components/NAMExplorer"; +import { + namCatalogSession, + namInstalledLibrarySession, + resetNAMExplorerSessionForTests, +} from "../services/namExplorerSession"; +import type { BuiltInPluginSchema, NAMCatalogTone, NAMInstalledModel } from "../services/NativeBridge"; +import { + filterAndPinNAMInstrumentItems, + labelForNAMInstrumentProfile, + namInstalledCaptureInstrumentLabels, + namInstrumentLabelsAreCompatible, + namInstrumentProfileTagIsCompatible, + namStoredPresetMatchesInstrumentProfile, + namPreEqBandLabelsForProfile, + namPreEqBandsForProfile, + normalizeNAMInstrumentProfile, + shouldClearNAMFactoryPresetIdentityOnProfileChange, +} from "../utils/namInstrumentProfile"; +import { + isCurrentNAMRackPresetState, + migrateLegacyNAMRackPresetDspState, + namInstrumentProfileMetadataFromRackState, +} from "../utils/namRackPresetTransactions"; + +describe("NAM Rack instrument profile", () => { + beforeEach(() => { + resetNAMExplorerSessionForTests(); + }); + + afterEach(() => { + resetNAMExplorerSessionForTests(); + }); + + it("defaults missing and malformed values to Guitar and keeps the enum binary", () => { + expect(normalizeNAMInstrumentProfile(undefined)).toBe(0); + expect(normalizeNAMInstrumentProfile(Number.NaN)).toBe(0); + expect(normalizeNAMInstrumentProfile(-5)).toBe(0); + expect(normalizeNAMInstrumentProfile(0.49)).toBe(0); + expect(normalizeNAMInstrumentProfile(0.5)).toBe(1); + expect(normalizeNAMInstrumentProfile(7)).toBe(0); + expect(labelForNAMInstrumentProfile(0)).toBe("Guitar"); + expect(labelForNAMInstrumentProfile(1)).toBe("Bass"); + }); + + it("keeps untagged/shared captures discoverable and hides only explicit opposite-instrument metadata", () => { + expect(namInstrumentLabelsAreCompatible([], 0)).toBe(true); + expect(namInstrumentLabelsAreCompatible(["Electric Guitar"], 0)).toBe(true); + expect(namInstrumentLabelsAreCompatible(["Electric Guitar"], 1)).toBe(false); + expect(namInstrumentLabelsAreCompatible(["Bass Guitar"], 0)).toBe(false); + expect(namInstrumentLabelsAreCompatible(["Bass Guitar"], 1)).toBe(true); + expect(namInstrumentLabelsAreCompatible(["Keys"], 1)).toBe(true); + expect(namInstrumentLabelsAreCompatible(["Guitar and Bass"], 0)).toBe(true); + expect(namInstrumentLabelsAreCompatible(["Guitar and Bass"], 1)).toBe(true); + expect(namInstrumentProfileTagIsCompatible("guitar", 0)).toBe(true); + expect(namInstrumentProfileTagIsCompatible("guitar", 1)).toBe(false); + expect(namInstrumentProfileTagIsCompatible("all", 1)).toBe(true); + }); + + it("clears only a factory identity that becomes incompatible after a real profile change", () => { + expect(shouldClearNAMFactoryPresetIdentityOnProfileChange("guitar", 0, 1)).toBe(true); + expect(shouldClearNAMFactoryPresetIdentityOnProfileChange("bass", 1, 0)).toBe(true); + expect(shouldClearNAMFactoryPresetIdentityOnProfileChange("all", 0, 1)).toBe(false); + expect(shouldClearNAMFactoryPresetIdentityOnProfileChange("guitar", 0, 0)).toBe(false); + expect(shouldClearNAMFactoryPresetIdentityOnProfileChange(undefined, 0, 1)).toBe(false); + }); + + it("reads installed instrument metadata and pins an active cross-instrument capture", () => { + const activeGuitar = { + id: "active-guitar", + instrument: "Electric Guitar", + }; + const items = [ + { id: "bass", latestMetadata: { target_instrument: { name: "Bass Guitar" } } }, + { id: "other-guitar", lastSeenMetadata: { instruments: ["Electric Guitar"] } }, + { id: "shared" }, + activeGuitar, + ]; + + expect(namInstalledCaptureInstrumentLabels(items[0])).toEqual(["Bass Guitar"]); + expect(filterAndPinNAMInstrumentItems( + items, + 1, + namInstalledCaptureInstrumentLabels, + (item) => item === activeGuitar, + ).map(({ id }) => id)).toEqual([ + "active-guitar", + "bass", + "shared", + ]); + }); + + it("uses migrated rack values, not sidecar metadata, as the preset profile authority", () => { + expect(namInstrumentProfileMetadataFromRackState({ + values: { instrumentProfile: 1 }, + })).toBe("bass"); + expect(namInstrumentProfileMetadataFromRackState({ + values: { instrumentProfile: 0 }, + })).toBe("guitar"); + expect(namInstrumentProfileMetadataFromRackState({ + values: { instrumentProfile: 99 }, + })).toBe("guitar"); + }); + + it("keeps canonical Bass presets visible when a stale sidecar says Guitar", () => { + const preset = { + instrumentProfile: "bass" as const, + metadata: { instrumentProfile: "guitar" as const }, + }; + expect(namStoredPresetMatchesInstrumentProfile(preset, 1)).toBe(true); + expect(namStoredPresetMatchesInstrumentProfile(preset, 0)).toBe(false); + }); + + it("keeps canonical Guitar presets visible when a stale sidecar says Bass", () => { + const preset = { + instrumentProfile: "guitar" as const, + metadata: { instrumentProfile: "bass" as const }, + }; + expect(namStoredPresetMatchesInstrumentProfile(preset, 0)).toBe(true); + expect(namStoredPresetMatchesInstrumentProfile(preset, 1)).toBe(false); + }); + + it("maps the stable EQ Boost IDs to profile-specific centers and user-facing labels", () => { + const guitarBands = namPreEqBandsForProfile(0); + const bassBands = namPreEqBandsForProfile(1); + + expect(guitarBands.map(({ paramId }) => paramId)).toEqual( + bassBands.map(({ paramId }) => paramId), + ); + expect(guitarBands.map(({ frequencyHz }) => frequencyHz)).toEqual([ + 120, 250, 500, 1000, 2500, 5000, 8000, 12000, + ]); + expect(bassBands.map(({ frequencyHz }) => frequencyHz)).toEqual([ + 50, 120, 250, 500, 800, 1600, 4500, 10000, + ]); + expect(guitarBands.map(({ faceplateLabel }) => faceplateLabel)).toEqual([ + "120", "250", "500", "1K", "2.5K", "5K", "8K", "12K", + ]); + expect(bassBands.map(({ faceplateLabel }) => faceplateLabel)).toEqual([ + "50", "120", "250", "500", "800", "1.6K", "4.5K", "10K", + ]); + expect(namPreEqBandLabelsForProfile(1)).toMatchObject({ + preEq120Db: "50 Hz", + preEq250Db: "120 Hz", + preEq500Db: "250 Hz", + preEq1kDb: "500 Hz", + preEq2k5Db: "800 Hz", + preEq5kDb: "1.6 kHz", + preEq8kDb: "4.5 kHz", + preEq12kDb: "10 kHz", + }); + }); + + it("migrates legacy complete presets and every latent comparison snapshot to Guitar", () => { + const migrated = migrateLegacyNAMRackPresetDspState({ + values: {}, + dspState: { namEffectsDspVersion: 7, reverbEngineVersion: 4 }, + uiState: { + namPresetBaseline: { values: {}, dspState: {} }, + namRackCompare: { + snapshots: { + A: { values: {}, dspState: {} }, + B: { values: { instrumentProfile: 1 }, dspState: { namEffectsDspVersion: 8 } }, + }, + }, + }, + }, { completePreset: true }) as any; + + expect(migrated.values.instrumentProfile).toBe(0); + expect(migrated.uiState.namPresetBaseline.values.instrumentProfile).toBe(0); + expect(migrated.uiState.namRackCompare.snapshots.A.values.instrumentProfile).toBe(0); + expect(migrated.uiState.namRackCompare.snapshots.B.values.instrumentProfile).toBe(1); + expect(isCurrentNAMRackPresetState(migrated)).toBe(true); + + const { instrumentProfile: _removed, ...incompleteValues } = migrated.values; + expect(isCurrentNAMRackPresetState({ ...migrated, values: incompleteValues })).toBe(false); + + const malformed = migrateLegacyNAMRackPresetDspState({ + values: { instrumentProfile: 9 }, + dspState: { namEffectsDspVersion: 8, reverbEngineVersion: 4 }, + }, { completePreset: true }) as any; + expect(malformed.values.instrumentProfile).toBe(0); + + const preV8Collision = migrateLegacyNAMRackPresetDspState({ + values: { instrumentProfile: 1 }, + dspState: { namEffectsDspVersion: 7, reverbEngineVersion: 4 }, + }, { completePreset: true }) as any; + expect(preV8Collision.values.instrumentProfile).toBe(0); + }); + + it("plumbs the saved enum through boot/mock schemas, all Explorer surfaces, and the header card", () => { + const bridge = readFileSync(new URL("../services/NativeBridge.ts", import.meta.url), "utf8"); + const boot = readFileSync(new URL("../components/BuiltInPluginPanel.tsx", import.meta.url), "utf8"); + const panel = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + const design = readFileSync(new URL("../components/NAMRackDesignPort.tsx", import.meta.url), "utf8"); + + expect(bridge).toContain('param("instrumentProfile", "Instrument", 0, 0, 1, "", "global", "enum", false)'); + expect(boot).toContain('makeFallbackParam("instrumentProfile", "Instrument", 0, 0, 1'); + expect(panel.match(/instrumentProfile=\{instrumentProfile\}/g)).toHaveLength(2); + expect(panel).toMatch(/utilityControls=\{\{\s*instrumentProfile,/); + expect(panel).toContain('id: "bass-clean-foundation"'); + expect(panel).toContain('id: "bass-grit-parallel"'); + expect(panel).toContain("designPortCompatibleInstalledCaptures"); + expect(panel).toContain("namInstalledCaptureInstrumentLabels"); + expect(panel).toContain('instrumentLabels.join(" / ")'); + expect(panel).toContain("clearIncompatibleFactoryIdentity"); + expect(panel).toContain("instrumentProfile: namInstrumentProfileMetadataFromRackState(loadedState)"); + expect(panel).toContain("instrumentProfile: namInstrumentProfileMetadataFromRackState(importedState)"); + expect(panel.match(/namStoredPresetMatchesInstrumentProfile\(entry, instrumentProfile\)/g)).toHaveLength(2); + expect(bridge).toContain('instrumentProfile?: "guitar" | "bass"'); + expect(panel).not.toContain("const savedProfile = presetMetadata[entry.name]?.instrumentProfile"); + expect(design).toContain('useBoundDesignParam("instrumentProfile")'); + }); + + it("keeps active opposite-tagged Explorer captures visible before profile filtering", () => { + const explorer = readFileSync(new URL("../components/NAMExplorer.tsx", import.meta.url), "utf8"); + expect(explorer.match(/filterAndPinNAMInstrumentItems\(/g)?.length).toBeGreaterThanOrEqual(2); + expect(explorer).toContain("installedRecordIsActive"); + expect(explorer).toContain("activeCapturePaths"); + expect(explorer).toContain("captureOptionsForInstrumentProfile"); + expect(explorer).toContain("catalogModelsForInstrumentProfile"); + expect(explorer).not.toContain("if (!namInstrumentLabelsAreCompatible(instrumentLabels, instrumentProfile)) return false;"); + }); + + it("pins active opposite-profile catalog rows by either stable model ID or URL-only identity", () => { + const urlOnlyModelUrl = "https://tone3000.example/models/url-only-active.nam"; + const catalog: NAMCatalogTone[] = [ + { + id: 10, + title: "URL-only active guitar capture", + sortBucket: "trending", + models: [{ id: 0, name: "URL capture", model_url: urlOnlyModelUrl, instrument: "Electric Guitar" }], + }, + { + id: 11, + title: "ID active guitar capture", + sortBucket: "trending", + models: [{ id: 101, name: "ID capture", model_url: "https://tone3000.example/models/id-active.nam", instrument: "Electric Guitar" }], + }, + { + id: 12, + title: "Inactive guitar capture", + sortBucket: "trending", + models: [{ id: 102, name: "Inactive capture", model_url: "https://tone3000.example/models/inactive.nam", instrument: "Electric Guitar" }], + }, + { + id: 20, + title: "Compatible bass capture", + sortBucket: "trending", + models: [{ id: 201, name: "Bass capture", model_url: "https://tone3000.example/models/bass.nam", instrument: "Bass Guitar" }], + }, + ]; + const installed: NAMInstalledModel[] = [ + { + modelId: 0, + toneId: 10, + modelUrl: urlOnlyModelUrl.toUpperCase(), + localPath: "C:\\NAM\\url-only-active.nam", + }, + { + modelId: 101, + toneId: 11, + modelUrl: "https://tone3000.example/models/id-active.nam", + localPath: "C:\\NAM\\id-active.nam", + }, + ]; + namCatalogSession.set({ tones: catalog, generatedAt: "", source: "test" }); + namInstalledLibrarySession.set({ installed }); + + const schema: BuiltInPluginSchema = { + schemaVersion: 1, + name: "NAM Rack", + category: "NAM", + chain: "track", + fxIndex: 0, + parameters: [], + modelState: { + ampModelPath: "c:/nam/URL-ONLY-ACTIVE.nam", + pedalModelPath: "c:/nam/id-active.nam", + }, + }; + const markup = renderToStaticMarkup(createElement(NAMExplorer, { + address: { trackId: "track-1", chain: "track", fxIndex: 0 }, + schema, + onRefreshRack: () => schema, + instrumentProfile: 1, + })); + + expect(markup).toContain("URL-only active guitar capture"); + expect(markup).toContain("ID active guitar capture"); + expect(markup).toContain("Compatible bass capture"); + expect(markup).not.toContain("Inactive guitar capture"); + }); + + it("routes the effective EQ Boost centers through DSP, live switching, stage labels, and accessibility", () => { + const dsp = readFileSync(new URL("../../../Source/BuiltInEffects2.cpp", import.meta.url), "utf8"); + const engine = readFileSync(new URL("../../../Source/AudioEngine.cpp", import.meta.url), "utf8"); + const panel = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + const design = readFileSync(new URL("../components/NAMRackDesignPort.tsx", import.meta.url), "utf8"); + + expect(dsp).toContain("kNAMRackPreEqFrequenciesByProfile"); + expect(dsp).toMatch(/50\.0f, 120\.0f, 250\.0f, 500\.0f,[\s\S]*800\.0f, 1600\.0f, 4500\.0f, 10000\.0f/); + expect(dsp).toContain("profile != lastPreEqInstrumentProfile"); + expect(engine).toContain("ProfileResponseStage::preEq"); + expect(engine).toMatch(/processDualOctaverStage\(block\);\s*liveRack\.processPreEQ\(block\);/); + expect(panel.match(/namPreEqBandLabelsForProfile\(instrumentProfile\)/g)?.length).toBe(1); + expect(design).toContain("namPreEqBandsForProfile(instrumentProfile?.value)"); + expect(design).toContain("semanticLabel={`${band.accessibleLabel} EQ Boost`}"); + }); +}); diff --git a/frontend/src/__tests__/namMeterLevel.test.ts b/frontend/src/__tests__/namMeterLevel.test.ts new file mode 100644 index 0000000..3a97225 --- /dev/null +++ b/frontend/src/__tests__/namMeterLevel.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + resolveNAMChannelMeterDb, + resolveNAMLinkedMeterDb, +} from "../utils/namMeterLevel"; + +describe("NAM Rack channel meter telemetry", () => { + it("prefers current independent channel diagnostics over linked and schema values", () => { + const diagnostics = { + inputLevelDb: -5, + inputLeftLevelDb: -7, + inputRightLevelDb: -19, + }; + + expect(resolveNAMLinkedMeterDb("input", diagnostics, -30)).toBe(-5); + expect(resolveNAMChannelMeterDb("input", "left", diagnostics, -31, -5)).toBe(-7); + expect(resolveNAMChannelMeterDb("input", "right", diagnostics, -32, -5)).toBe(-19); + }); + + it("uses a live linked value only as compatibility fallback for an older native build", () => { + const diagnostics = { outputLevelDb: -8 }; + + expect(resolveNAMChannelMeterDb("output", "left", diagnostics, -20, -8)).toBe(-8); + expect(resolveNAMChannelMeterDb("output", "right", diagnostics, -26, -8)).toBe(-8); + }); + + it("uses independent schema channels before the first live diagnostic poll", () => { + expect(resolveNAMChannelMeterDb("input", "left", null, -14, -4)).toBe(-14); + expect(resolveNAMChannelMeterDb("input", "right", null, -23, -4)).toBe(-23); + }); +}); diff --git a/frontend/src/__tests__/namMultiCaptureLifecycle.test.ts b/frontend/src/__tests__/namMultiCaptureLifecycle.test.ts new file mode 100644 index 0000000..a66c1e1 --- /dev/null +++ b/frontend/src/__tests__/namMultiCaptureLifecycle.test.ts @@ -0,0 +1,147 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { captureTypeForToneModel } from "../utils/namCaptureType"; + +const explorerSource = readFileSync( + new URL("../components/NAMExplorer.tsx", import.meta.url), + "utf8", +).replace(/\r\n?/g, "\n"); +const designPortSource = readFileSync( + new URL("../components/NAMRackDesignPort.tsx", import.meta.url), + "utf8", +).replace(/\r\n?/g, "\n"); +const rackPanelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", +).replace(/\r\n?/g, "\n"); + +describe("NAM multi-capture lifecycle", () => { + beforeEach(() => { + vi.stubGlobal("window", { + location: { search: "?mockPlugin=nam&mockNAMScenario=empty-rack" }, + setTimeout, + }); + }); + + it("hydrates the deterministic pack with four exact child identities", async () => { + const detail = await nativeBridge.getTONE3000ToneDetail(67139, "a2"); + expect(detail.success).toBe(true); + expect(detail.tone?.title).toBe("Headbangers Ball Amp Pack IR/RAW"); + expect(detail.models?.map((model) => model.model_id)).toEqual([ + 6713901, + 6713902, + 6713903, + 6713904, + ]); + expect(detail.models?.map((model) => captureTypeForToneModel(detail.tone!, model))).toEqual([ + "amp", + "amp_cab", + "amp", + "amp_cab", + ]); + }); + + it("keeps preview and durable paths distinct for every selected child", async () => { + const detail = await nativeBridge.getTONE3000ToneDetail(67139, "a2"); + const rawModel = detail.models![0]; + const irModel = detail.models![1]; + const rawPreview = await nativeBridge.installNAMModel(rawModel, { mode: "preview" }); + const irPreview = await nativeBridge.installNAMModel(irModel, { mode: "preview" }); + + expect(rawPreview.record?.modelId).toBe(6713901); + expect(irPreview.record?.modelId).toBe(6713902); + expect(rawPreview.record?.localPath).not.toBe(irPreview.record?.localPath); + expect(rawPreview.record?.localPath.replace(/\\/g, "/")).toContain("/previews/tone-67139/"); + + const committed = await nativeBridge.commitNAMPreviewTone(irPreview.record!, { + toneName: detail.tone!.title!, + }); + expect(committed.success).toBe(true); + expect(committed.record?.modelId).toBe(6713902); + expect(committed.record?.preview).toBe(false); + expect(committed.record?.localPath.replace(/\\/g, "/")).toContain("/library/tone-67139/"); + expect(committed.record?.localPath).not.toBe(irPreview.record?.localPath); + }); + + it("replaces RAW with cab-embedded, bypasses, re-enables, and clears without stale identity", async () => { + const address = { chain: "track" as const, trackId: "nam-multi-capture-lifecycle", fxIndex: 0 }; + const detail = await nativeBridge.getTONE3000ToneDetail(67139, "a2"); + const raw = await nativeBridge.installNAMModel(detail.models![0], { mode: "preview" }); + const embedded = await nativeBridge.installNAMModel(detail.models![1], { mode: "preview" }); + + await expect(nativeBridge.setBuiltInPluginState(address, { + modelState: { + ampModelPath: raw.record!.localPath, + ampModelSize: 1, + ampDeclaredCaptureType: "amp", + cabRequestedEnabled: true, + }, + values: { ampEnabled: 1, ampMix: 1, cabEnabled: 1 }, + })).resolves.toBe(true); + let state = await nativeBridge.getBuiltInPluginState(address); + expect(state.modelState).toMatchObject({ + ampModelPath: raw.record!.localPath, + ampCaptureType: "amp", + ampIncludesCab: false, + ampModelSize: 1, + }); + expect(state.values?.cabEnabled).toBe(1); + + await expect(nativeBridge.setBuiltInPluginState(address, { + modelState: { + ampModelPath: embedded.record!.localPath, + ampModelSize: 1, + ampDeclaredCaptureType: "amp_cab", + cabRequestedEnabled: true, + }, + values: { ampEnabled: 1, ampMix: 1 }, + })).resolves.toBe(true); + state = await nativeBridge.getBuiltInPluginState(address); + expect(state.modelState).toMatchObject({ + ampModelPath: embedded.record!.localPath, + ampCaptureType: "amp_cab", + ampIncludesCab: true, + cabRequestedEnabled: true, + }); + expect(state.values?.cabEnabled).toBe(0); + + await expect(nativeBridge.setBuiltInPluginParam(address, "ampEnabled", 0)).resolves.toBe(true); + expect((await nativeBridge.getBuiltInPluginState(address)).values?.ampEnabled).toBe(0); + await expect(nativeBridge.setBuiltInPluginParam(address, "ampEnabled", 1)).resolves.toBe(true); + expect((await nativeBridge.getBuiltInPluginState(address)).values?.ampEnabled).toBe(1); + + await expect(nativeBridge.setBuiltInPluginState(address, { + modelState: { clearAmpModel: true }, + })).resolves.toBe(true); + state = await nativeBridge.getBuiltInPluginState(address); + expect(state.modelState).toMatchObject({ + ampModelPath: "", + hasAmpModel: false, + ampCaptureType: "unknown", + ampIncludesCab: false, + }); + }); + + it("requires an explicit child and exposes the picker on desktop and compact source-flow surfaces", () => { + expect(explorerSource).toContain("namToneRequiresExplicitCapture(tone) && !rowHasExplicitSelection"); + expect(explorerSource).toContain("isExplicitNAMCatalogCaptureSelection(selectedKey, row.key, tone, model)"); + expect(explorerSource).toContain("namCatalogCaptureSelectionKey(tone, model)"); + expect(explorerSource).toContain("activePreview.key,\n selected.catalogRow.key"); + expect(explorerSource).toContain("Choose a specific capture in this pack before auditioning"); + expect(explorerSource).toContain("Choose a specific capture in this pack before using it"); + expect(explorerSource).toContain("if (models.length > 1)"); + expect(explorerSource).toContain('mode === "preview" && catalogAuditionIsActive(catalogRow)'); + expect(explorerSource).toContain('mode === "preview" && installedAuditionIsActive(installedRecord)'); + expect(explorerSource).not.toContain("models.find((candidate) => preferredTargetForToneModel(tone, candidate) === requestedTarget)"); + expect(designPortSource).toContain("className=\"tone-compact-capture-picker\""); + expect((designPortSource.match(/ { + beforeEach(() => { + bridgeInternals.isNative = true; + (globalThis as { window?: unknown }).window = { __JUCE__: { backend: {} } }; + }); + + afterEach(() => { + bridgeInternals.isNative = false; + if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; + else (globalThis as { window?: unknown }).window = originalWindow; + }); + + it("reports missing endpoints as unsupported inside a native host", async () => { + await expect(nativeBridge.getNAMRackOversamplingFactor()).rejects.toThrow("does not support"); + await expect(nativeBridge.setNAMRackOversamplingFactor(8)).resolves.toBe(false); + await expect(nativeBridge.setNAMTunerActive("track-a", true, "subscriber-a")).resolves.toBe(false); + await expect(nativeBridge.setTrackInputMonitoring("track-a", true)).resolves.toBe(false); + }); + + it("retains deterministic mocks for frontend-only development", async () => { + bridgeInternals.isNative = false; + await expect(nativeBridge.setNAMRackOversamplingFactor(8)).resolves.toBe(true); + await expect(nativeBridge.getNAMRackOversamplingFactor()).resolves.toBe(8); + await expect(nativeBridge.setNAMTunerActive("track-a", true, "subscriber-a")).resolves.toBe(true); + await expect(nativeBridge.setTrackInputMonitoring("track-a", true)).resolves.toBe(true); + }); +}); diff --git a/frontend/src/__tests__/namParameterWheelIntegration.test.ts b/frontend/src/__tests__/namParameterWheelIntegration.test.ts new file mode 100644 index 0000000..33e0891 --- /dev/null +++ b/frontend/src/__tests__/namParameterWheelIntegration.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import designPortSource from "../components/NAMRackDesignPort.tsx?raw"; +import rackKnobSource from "../components/NAMRackKnob.tsx?raw"; +import rackPanelSource from "../components/NAMRackPanel.tsx?raw"; + +interface WheelSourceContract { + name: string; + source: string; + handlerCount: number; + parameterHandlerCount: number; + subtarget: "control" | "graph"; +} + +const wheelSources: readonly WheelSourceContract[] = [ + // AssetControl, the vertical Fader, and HorizontalMiniFader are parameter + // handlers. The fourth onWheel belongs only to the PRE-row scroller. + { name: "design port", source: designPortSource, handlerCount: 4, parameterHandlerCount: 3, subtarget: "control" }, + { name: "rack knob", source: rackKnobSource, handlerCount: 1, parameterHandlerCount: 1, subtarget: "control" }, + { name: "rack panel", source: rackPanelSource, handlerCount: 1, parameterHandlerCount: 1, subtarget: "control" }, +]; + +function occurrenceCount(source: string, pattern: RegExp): number { + return source.match(pattern)?.length ?? 0; +} + +describe("NAM parameter wheel integration", () => { + it.each(wheelSources)( + "routes every $name wheel handler through the profiled resolver", + ({ source, handlerCount, parameterHandlerCount, subtarget }) => { + expect(occurrenceCount(source, /\bonWheel=/g)).toBe(handlerCount); + expect(occurrenceCount( + source, + new RegExp(`resolveProfiledParameterWheel\\(event\\.nativeEvent, "${subtarget}"\\)`, "g"), + )).toBe(parameterHandlerCount); + expect(occurrenceCount( + source, + /getParameterWheelStepCount\(gesture/g, + )).toBe(parameterHandlerCount); + expect(occurrenceCount( + source, + /if \(gesture\.preventDefault\) event\.preventDefault\(\);/g, + )).toBe(parameterHandlerCount); + expect(occurrenceCount( + source, + /if \(gesture\.stopPropagation\) event\.stopPropagation\(\);/g, + )).toBe(parameterHandlerCount); + expect(occurrenceCount( + source, + /if \(gesture\.operation !== "adjust"\) return;/g, + )).toBe(parameterHandlerCount); + }, + ); + + it("uses the resolved precision instead of raw Ctrl, Command, or Shift state", () => { + for (const { source, parameterHandlerCount } of wheelSources) { + let cursor = 0; + for (let index = 0; index < parameterHandlerCount; index += 1) { + const resolverStart = source.indexOf( + "const gesture = resolveProfiledParameterWheel(event.nativeEvent", + cursor, + ); + expect(resolverStart).toBeGreaterThanOrEqual(0); + const stepStart = source.indexOf("getParameterWheelStepCount(gesture", resolverStart); + expect(stepStart).toBeGreaterThan(resolverStart); + const resolutionEnd = source.indexOf(");", stepStart) + 2; + expect(resolutionEnd).toBeGreaterThan(stepStart); + const resolutionBlock = source.slice(resolverStart, resolutionEnd); + expect(resolutionBlock).not.toMatch( + /event\.(?:ctrlKey|metaKey|shiftKey|deltaY)/, + ); + cursor = resolutionEnd; + } + } + }); + + it("keeps the extra PRE-row wheel handler dedicated to horizontal scrolling", () => { + expect(occurrenceCount(designPortSource, /\bonWheel=/g)).toBe(4); + expect(occurrenceCount( + designPortSource, + /resolveProfiledParameterWheel\(event\.nativeEvent, "control"\)/g, + )).toBe(3); + expect(designPortSource).toContain('className="nam-pre-stage-scroll"'); + expect(designPortSource).toContain('target.closest(".control-hit, .horizontal-mini-fader")'); + expect(designPortSource).toContain("event.currentTarget.scrollLeft += delta"); + }); + +}); diff --git a/frontend/src/__tests__/namPedalCaptureControls.test.ts b/frontend/src/__tests__/namPedalCaptureControls.test.ts new file mode 100644 index 0000000..beaeff7 --- /dev/null +++ b/frontend/src/__tests__/namPedalCaptureControls.test.ts @@ -0,0 +1,51 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + NAM_RACK_ADVANCED_CONTROL_IDS, + NAM_RACK_ADVANCED_ONLY_CONTROL_IDS, + namRackAdvancedStageForCompactModule, + orderNAMRackMixerStages, + type RackMixerStripSpec, +} from "../components/NAMRackMixer"; + +const stage = (id: string): RackMixerStripSpec => ({ + id, + label: id, + caption: id, + active: false, + params: [], +}); + +describe("NAM Pedal Capture controls", () => { + it("exposes the real wet/dry control as its own Device Controls stage", () => { + expect(NAM_RACK_ADVANCED_CONTROL_IDS["pedal-capture"]).toEqual(["pedalMix"]); + expect(NAM_RACK_ADVANCED_ONLY_CONTROL_IDS["pedal-capture"]).toEqual(["pedalMix"]); + expect(namRackAdvancedStageForCompactModule("pedal-capture")).toBe("pedal-capture"); + + const ordered = orderNAMRackMixerStages([ + stage("amp"), + stage("pedal-capture"), + stage("chaos"), + ], []); + expect(ordered.map((entry) => entry.id)).toEqual(["chaos", "pedal-capture", "amp"]); + }); + + it("shows loaded identity, Mix, and truthful bypass semantics in the compact chain", () => { + const panelSource = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + const captureCoreStart = panelSource.indexOf("const signalChainCaptureCore"); + const captureCoreEnd = panelSource.indexOf("const signalChainPost", captureCoreStart); + const captureCore = panelSource.slice(captureCoreStart, captureCoreEnd); + + expect(panelSource).toContain('const togglePedalCapture = () => toggleParamPower("pedal", pedalMix, pedalActive, 0, 1)'); + expect(panelSource).toContain('label: "Pedal Capture"'); + expect(panelSource).toContain('params: stageParams(NAM_RACK_ADVANCED_ONLY_CONTROL_IDS["pedal-capture"], { pedalMix: "Mix" })'); + expect(panelSource).toContain("Mix is also this capture's power control: 0% is a true bypass."); + expect(captureCore).toContain('id: "pedal-capture"'); + expect(captureCore).toContain('caption: pedalName || "No capture loaded"'); + expect(captureCore).toContain('? formatPercentParam(pedalMix, "Mix", "Engaged")'); + expect(captureCore).toContain(': "Bypassed · Mix 0%"'); + expect(captureCore).toContain('onToggle: hasPedalModel && pedalMix ? togglePedalCapture : undefined'); + expect(captureCore.indexOf('id: "pedal-capture"')).toBeLessThan(captureCore.indexOf('id: "amp-nam"')); + }); +}); diff --git a/frontend/src/__tests__/namPortableState.test.ts b/frontend/src/__tests__/namPortableState.test.ts new file mode 100644 index 0000000..06c5878 --- /dev/null +++ b/frontend/src/__tests__/namPortableState.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { + isRetiredNAMRackAutomationParamId, + normalizeNAMEffectsDspVersion, + omitNAMNonPortableState, + pruneRetiredNAMRackInputRoutingState, + sanitizeNAMRackDspState, + sanitizeNAMRackPortableDspState, +} from "../utils/namPortableState"; + +describe("portable NAM Rack state", () => { + it("recursively omits interface, runtime, and retired effect state", () => { + const state = { + calibrationReferenceDbu: -10, + auditionSource: 1, + inputMode: 2, + laserEnabled: 1, + laserMode: 4, + laserMix: 0.5, + laserSpeedHz: 2.5, + laserSensitivity: 0.6, + laserEnvelopeMode: 1, + laserTrigger: 1, + precisionDriveMode: 1, + tapeEchoEnabled: 1, + tapeEchoMix: 0.6, + reverbCharacter: 2, + reverbFreeze: 1, + inputTrimDb: 2, + chaosGate: 0.22, + cabRoomEnabled: 0, + cabRoomAmount: 0.73, + cabDoublerEnabled: 1, + cabDoublerMix: 0.24, + uiState: { + compare: { + calibrationReferenceDbu: 4, + auditionSource: 1, + inputMode: 0, + laserEnabled: 1, + laserMode: 2, + laserMix: 0.4, + laserTrigger: 1, + precisionDriveMode: 1, + tapeEchoFeedback: 0.72, + reverbWidth: 0.4, + reverbShimmerRegen: 0.8, + ampMix: 0.75, + }, + }, + }; + + const json = JSON.stringify(state, omitNAMNonPortableState); + expect(json).not.toContain("calibrationReferenceDbu"); + expect(json).not.toContain("auditionSource"); + expect(json).not.toContain("inputMode"); + expect(json).not.toContain("laser"); + expect(json).not.toContain("precisionDriveMode"); + expect(json).not.toContain("tapeEcho"); + expect(json).not.toContain("reverbCharacter"); + expect(json).not.toContain("reverbFreeze"); + expect(json).not.toContain("reverbWidth"); + expect(json).not.toContain("reverbShimmerRegen"); + expect(JSON.parse(json)).toEqual({ + inputTrimDb: 2, + chaosGate: 0.22, + cabRoomEnabled: 0, + cabRoomAmount: 0.73, + cabDoublerEnabled: 1, + cabDoublerMix: 0.24, + uiState: { compare: { ampMix: 0.75 } }, + }); + }); + + it("also strips those keys while importing a bundle", () => { + const imported = JSON.parse( + '{"state":{"auditionSource":1,"inputMode":2,"laserEnabled":1,"laserMode":5,"laserTrigger":1,"precisionDriveMode":1,"tapeEchoEnabled":1,"tapeEchoTimeMs":420,"calibrationReferenceDbu":-18,"outputTrimDb":-1}}', + omitNAMNonPortableState, + ); + expect(imported).toEqual({ state: { outputTrimDb: -1 } }); + }); + + it("recursively prunes only the retired routing selector from live-compatible state", () => { + expect(pruneRetiredNAMRackInputRoutingState({ + values: { inputMode: 2, auditionSource: 1, ampMix: 0.75 }, + uiState: { + namPresetBaseline: { values: { inputMode: 0, delayMix: 0.2 } }, + list: [{ inputMode: 2, keep: true }], + }, + })).toEqual({ + values: { auditionSource: 1, ampMix: 0.75 }, + uiState: { + namPresetBaseline: { values: { delayMix: 0.2 } }, + list: [{ keep: true }], + }, + }); + }); + + it("identifies retired effect and Precision mode automation lanes without touching current parameters", () => { + const legacyLanes = [ + { param: "builtin_track_0_laserEnabled", points: [{ time: 0, value: 0 }] }, + { param: "builtin_input_12_laserTrigger", points: [{ time: 2, value: 1 }] }, + { param: "builtin_track_0_reverbCharacter", points: [{ time: 0, value: 2 }] }, + { param: "builtin_track_0_reverbFreeze", points: [{ time: 1, value: 1 }] }, + { param: "builtin_input_12_reverbShimmerRegen", points: [{ time: 2, value: 0.8 }] }, + { param: "builtin_track_0_precisionDriveMode", points: [{ time: 3, value: 1 }] }, + { param: "builtin_track_0_compressorDetail", points: [{ time: 4, value: 0.55 }] }, + { param: "builtin_track_0_auditionSource", points: [{ time: 5, value: 1 }] }, + { param: "builtin_track_0_inputMode", points: [{ time: 6, value: 2 }] }, + { param: "builtin_track_0_tapeEchoEnabled", points: [{ time: 7, value: 1 }] }, + { param: "builtin_input_12_tapeEchoTone", points: [{ time: 8, value: 0.5 }] }, + ]; + + expect(legacyLanes.every((lane) => isRetiredNAMRackAutomationParamId(lane.param))).toBe(true); + expect(isRetiredNAMRackAutomationParamId("builtin_track_0_reverbShimmer")).toBe(false); + expect(isRetiredNAMRackAutomationParamId("builtin_track_42_compressorAttackMs")).toBe(false); + expect(isRetiredNAMRackAutomationParamId("builtin_track_42_compressorReleaseMs")).toBe(false); + expect(isRetiredNAMRackAutomationParamId("laserEnabled")).toBe(false); + }); + + it("canonicalizes recognized portable NAM Rack selectors to the current DSP", () => { + expect(normalizeNAMEffectsDspVersion(1)).toBe(19); + expect(normalizeNAMEffectsDspVersion("3")).toBe(19); + expect(normalizeNAMEffectsDspVersion(0)).toBeUndefined(); + expect(normalizeNAMEffectsDspVersion(4)).toBe(19); + expect(normalizeNAMEffectsDspVersion(5)).toBe(19); + expect(normalizeNAMEffectsDspVersion(6)).toBe(19); + expect(normalizeNAMEffectsDspVersion(7)).toBe(19); + expect(normalizeNAMEffectsDspVersion(8)).toBe(19); + expect(normalizeNAMEffectsDspVersion(9)).toBe(19); + expect(normalizeNAMEffectsDspVersion(10)).toBe(19); + expect(normalizeNAMEffectsDspVersion(11)).toBe(19); + expect(normalizeNAMEffectsDspVersion(12)).toBe(19); + expect(normalizeNAMEffectsDspVersion(13)).toBe(19); + expect(normalizeNAMEffectsDspVersion(14)).toBe(19); + expect(normalizeNAMEffectsDspVersion(15)).toBe(19); + expect(normalizeNAMEffectsDspVersion(16)).toBe(19); + expect(normalizeNAMEffectsDspVersion(17)).toBe(19); + expect(normalizeNAMEffectsDspVersion(18)).toBe(19); + expect(normalizeNAMEffectsDspVersion(19)).toBe(19); + expect(normalizeNAMEffectsDspVersion(20)).toBeUndefined(); + expect(sanitizeNAMRackDspState({ + reverbEngineVersion: 5, + namEffectsDspVersion: 19, + unknownEngineVersion: 99, + })).toEqual({ + reverbEngineVersion: 5, + namEffectsDspVersion: 19, + }); + expect(sanitizeNAMRackDspState({ + reverbEngineVersion: 2, + namEffectsDspVersion: 8, + })).toEqual({ reverbEngineVersion: 5, namEffectsDspVersion: 19 }); + }); + + it("keeps legacy PRE EQ bands alive until versioned preset migration runs", () => { + const parsed = JSON.parse(JSON.stringify({ + values: { + preEq100Db: -3.5, + preEq6k4Db: 2.25, + }, + dspState: { namEffectsDspVersion: 18 }, + }), omitNAMNonPortableState) as Record; + + expect(parsed.values).toMatchObject({ + preEq100Db: -3.5, + preEq6k4Db: 2.25, + }); + }); + + it("does not invent DSP selectors on generic partial state patches", () => { + const partialPatch = { values: { ampMix: 0.5 } }; + expect(sanitizeNAMRackPortableDspState(partialPatch)).toBe(partialPatch); + expect(sanitizeNAMRackPortableDspState({ + values: { ampMix: 0.5 }, + dspState: { namEffectsDspVersion: 9 }, + })).toEqual({ values: { ampMix: 0.5 }, dspState: { namEffectsDspVersion: 19 } }); + }); +}); diff --git a/frontend/src/__tests__/namPostFxFaceplateLayout.test.ts b/frontend/src/__tests__/namPostFxFaceplateLayout.test.ts new file mode 100644 index 0000000..a80b18d --- /dev/null +++ b/frontend/src/__tests__/namPostFxFaceplateLayout.test.ts @@ -0,0 +1,255 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + computePremiumStagePlacement, + NAM_PEDAL_HARDWARE_STANDARD_PX, + NAM_POST_FX_FACEPLATE_LAYOUT, +} from "../components/NAMRackDesignPort"; + +describe("NAM Rack tall Post FX faceplates", () => { + it("uses taller, non-overlapping boxes contained by the Post FX group", () => { + const { group, modules } = NAM_POST_FX_FACEPLATE_LAYOUT; + const boxes = Object.values(modules).map(({ box }) => box); + + expect(modules.modulator.box.h).toBeGreaterThan(157); + expect(modules.delay.box.h).toBeGreaterThan(182); + expect(modules.reverb.box.h).toBeGreaterThan(177); + + for (const box of boxes) { + expect(box.x).toBeGreaterThanOrEqual(group.x); + expect(box.y).toBeGreaterThanOrEqual(group.y); + expect(box.x + box.w).toBeLessThanOrEqual(group.x + group.w); + expect(box.y + box.h).toBeLessThanOrEqual(group.y + group.h); + } + + expect(modules.modulator.box.x + modules.modulator.box.w) + .toBeLessThan(modules.delay.box.x); + expect(modules.delay.box.x + modules.delay.box.w) + .toBeLessThan(modules.reverb.box.x); + }); + + it("reserves visible title and lower-edge padding on every faceplate", () => { + const layout = NAM_POST_FX_FACEPLATE_LAYOUT; + + for (const module of Object.values(layout.modules)) { + expect(module.titleY).toBeGreaterThanOrEqual(9); + expect(module.titleY).toBeLessThan(13); + } + + for (const controls of [layout.modulator, layout.delay, layout.reverb]) { + expect(controls.topRowY).toBeGreaterThan(controls === layout.reverb ? 25 : 30); + expect(controls.stateLabelY).toBeGreaterThan(controls.ledY); + expect(controls.footY).toBeGreaterThan(controls.stateLabelY); + expect(controls.footY).toBeLessThanOrEqual(96); + } + + const footBottomClearanceInDesignPixels = ( + box: { w: number; h: number }, + footY: number, + footSize: number, + ) => box.h - (footY * box.h / 100 + footSize * box.w / 200); + + expect(footBottomClearanceInDesignPixels(layout.modules.modulator.box, layout.modulator.footY, layout.modulator.footSize)).toBeGreaterThanOrEqual(6); + expect(footBottomClearanceInDesignPixels(layout.modules.delay.box, layout.delay.footY, layout.delay.footSize)).toBeGreaterThanOrEqual(6); + expect(footBottomClearanceInDesignPixels(layout.modules.reverb.box, layout.reverb.footY, layout.reverb.footSize)).toBeGreaterThanOrEqual(6); + expect(footBottomClearanceInDesignPixels(layout.modules.modulator.box, layout.modulator.footY, layout.modulator.footerToggleSize)).toBeGreaterThanOrEqual(6); + + // Percentage ordering alone missed real rendered collisions. Model the + // square LED/footswitch boxes and the measured label heights in each + // faceplate's own design pixels, and require a visible gap at every seam. + // The approved faceplate uses a compact 7 px label rhythm so the physical + // LED and switch photographs can retain realistic diameters. + const postLabelHeight = 6.2; + const stateLabelHeight = 8; + const expectFooterGaps = ( + box: { w: number; h: number }, + controls: { lowerRowY: number; lowerLabelOffset: number; ledY: number; ledSize: number; stateLabelY: number; footY: number; footSize: number }, + ) => { + const lowerLabelBottom = (controls.lowerRowY + controls.lowerLabelOffset) * box.h / 100 + postLabelHeight / 2; + const ledTop = controls.ledY * box.h / 100 - controls.ledSize * box.w / 200; + const ledBottom = controls.ledY * box.h / 100 + controls.ledSize * box.w / 200; + const stateCenter = controls.stateLabelY * box.h / 100; + const footTop = controls.footY * box.h / 100 - controls.footSize * box.w / 200; + const stateTop = stateCenter - stateLabelHeight / 2; + const stateBottom = stateCenter + stateLabelHeight / 2; + + expect(ledTop - lowerLabelBottom).toBeGreaterThanOrEqual(.75); + expect(stateTop - ledBottom).toBeGreaterThanOrEqual(1); + expect(footTop - stateBottom).toBeGreaterThanOrEqual(1); + }; + + expectFooterGaps(layout.modules.modulator.box, layout.modulator); + expectFooterGaps(layout.modules.delay.box, layout.delay); + expectFooterGaps(layout.modules.reverb.box, layout.reverb); + }); + + it("keeps each label outside its knob and leaves a measured gap before the next row", () => { + const { modules, modulator, delay, reverb } = NAM_POST_FX_FACEPLATE_LAYOUT; + const postLabelHeight = 6.2; + + const expectSeparatedRows = ( + box: { w: number; h: number }, + controls: { + topRowY: number; + topKnobSize: number; + topLabelOffset: number; + lowerRowY: number; + lowerKnobSize: number; + lowerLabelOffset: number; + }, + ) => { + const topKnobBottom = controls.topRowY * box.h / 100 + controls.topKnobSize * box.w / 200; + const topLabelTop = (controls.topRowY + controls.topLabelOffset) * box.h / 100 - postLabelHeight / 2; + const topLabelBottom = topLabelTop + postLabelHeight; + const lowerKnobTop = controls.lowerRowY * box.h / 100 - controls.lowerKnobSize * box.w / 200; + const lowerKnobBottom = controls.lowerRowY * box.h / 100 + controls.lowerKnobSize * box.w / 200; + const lowerLabelTop = (controls.lowerRowY + controls.lowerLabelOffset) * box.h / 100 - postLabelHeight / 2; + + expect(topLabelTop - topKnobBottom).toBeGreaterThanOrEqual(2); + expect(lowerKnobTop - topLabelBottom).toBeGreaterThanOrEqual(3.25); + expect(lowerLabelTop - lowerKnobBottom).toBeGreaterThanOrEqual(2); + }; + + expectSeparatedRows(modules.modulator.box, modulator); + expectSeparatedRows(modules.delay.box, delay); + expectSeparatedRows(modules.reverb.box, reverb); + }); + + it("gives every Post pedal one physical knob, toggle, footswitch, and LED size", () => { + const { modules, modulator, delay, reverb } = NAM_POST_FX_FACEPLATE_LAYOUT; + const entries = [ + { box: modules.modulator.box, controls: modulator }, + { box: modules.delay.box, controls: delay }, + { box: modules.reverb.box, controls: reverb }, + ]; + const physical = entries.map(({ box, controls }) => ({ + footDiameter: controls.footSize * box.w / 100, + ledDiameter: controls.ledSize * box.w / 100, + topKnobDiameter: controls.topKnobSize * box.w / 100, + lowerKnobDiameter: controls.lowerKnobSize * box.w / 100, + ledBaseline: box.y + controls.ledY * box.h / 100, + labelBaseline: box.y + controls.stateLabelY * box.h / 100, + footBaseline: box.y + controls.footY * box.h / 100, + })); + + for (const item of physical.slice(1)) { + expect(item.footDiameter).toBeCloseTo(physical[0].footDiameter, 3); + expect(item.ledDiameter).toBeCloseTo(physical[0].ledDiameter, 3); + expect(item.topKnobDiameter).toBeCloseTo(physical[0].topKnobDiameter, 3); + expect(item.lowerKnobDiameter).toBeCloseTo(physical[0].lowerKnobDiameter, 3); + expect(Math.abs(item.ledBaseline - physical[0].ledBaseline)).toBeLessThanOrEqual(.1); + expect(Math.abs(item.labelBaseline - physical[0].labelBaseline)).toBeLessThanOrEqual(2.5); + expect(Math.abs(item.footBaseline - physical[0].footBaseline)).toBeLessThanOrEqual(.6); + } + + expect(physical[0].footDiameter).toBe(NAM_PEDAL_HARDWARE_STANDARD_PX.footswitch); + expect(physical[0].ledDiameter).toBe(NAM_PEDAL_HARDWARE_STANDARD_PX.led); + expect(physical[0].topKnobDiameter).toBe(NAM_PEDAL_HARDWARE_STANDARD_PX.knob); + expect(physical[0].lowerKnobDiameter).toBe(NAM_PEDAL_HARDWARE_STANDARD_PX.knob); + expect(modulator.footerToggleSize * modules.modulator.box.w / 100) + .toBeCloseTo(NAM_PEDAL_HARDWARE_STANDARD_PX.toggle, 8); + expect(delay.secondaryFootSize * modules.delay.box.w / 100) + .toBeCloseTo(NAM_PEDAL_HARDWARE_STANDARD_PX.footswitch, 8); + expect(delay.secondaryLedSize * modules.delay.box.w / 100) + .toBeCloseTo(NAM_PEDAL_HARDWARE_STANDARD_PX.led, 8); + expect(modulator.headerToggleSize * modules.modulator.box.w / 100) + .toBeCloseTo(NAM_PEDAL_HARDWARE_STANDARD_PX.toggle, 8); + }); + + it("mirrors the Modulator status screens and header toggles without collisions", () => { + const { modulator } = NAM_POST_FX_FACEPLATE_LAYOUT; + const { modeDisplay, pedalModeDisplay } = modulator; + const displayCenterY = modeDisplay.y + modeDisplay.h / 2; + const toggleRadius = modulator.headerToggleSize / 2; + const leftScreenGap = modulator.modeToggleX - toggleRadius + - (modeDisplay.x + modeDisplay.w); + const rightScreenGap = pedalModeDisplay.x + - (modulator.pedalToggleX + toggleRadius); + + expect(displayCenterY).toBeCloseTo(modulator.headerCenterY, 5); + expect(pedalModeDisplay.y + pedalModeDisplay.h / 2) + .toBeCloseTo(modulator.headerCenterY, 5); + expect(modeDisplay.y).toBeGreaterThan(14); + expect(modulator.headerCenterY).toBeLessThan(modulator.topRowY - 12); + expect(modeDisplay.w).toBe(pedalModeDisplay.w); + expect(modeDisplay.h).toBe(pedalModeDisplay.h); + expect(modeDisplay.x).toBe(100 - pedalModeDisplay.x - pedalModeDisplay.w); + expect(modulator.modeToggleX).toBe(100 - modulator.pedalToggleX); + expect(leftScreenGap).toBeGreaterThan(0); + expect(rightScreenGap).toBeCloseTo(leftScreenGap, 8); + }); + + it("puts both Modulator states inside screens while preserving their toggles", () => { + const source = readFileSync( + new URL("../components/NAMRackDesignPort.tsx", import.meta.url), + "utf8", + ); + const stage = source.match(/function PostFxStage\(\)[\s\S]*?function SectionStage\(/)?.[0] ?? ""; + + expect(stage).toContain('className="mod-header-display mod-mode-display"'); + expect(stage).toContain('className="mod-header-display mod-pedal-mode-display"'); + expect(stage).toContain('paramId="modulatorMode"'); + expect(stage).toContain('paramId="modulatorPedalMode"'); + expect(stage).toContain('offLabel="PEDAL"'); + expect(stage).toContain('onLabel="AUTO"'); + expect(stage).not.toMatch(/\s*MODE\s*<\/Label>/); + expect(stage).not.toContain("mod-switch-state"); + }); + + it("fits all three taller bodies within compact through Max stage placements", () => { + const viewports = [ + { width: 720, height: 410 }, + { width: 1010, height: 520 }, + { width: 3530, height: 1946 }, + ]; + const sizes = [80, 100, 140, 180, 220]; + const { group, modules } = NAM_POST_FX_FACEPLATE_LAYOUT; + + for (const viewport of viewports) { + for (const size of sizes) { + const placement = computePremiumStagePlacement(viewport, group, size); + for (const { box } of Object.values(modules)) { + const left = placement.left + box.x * placement.scale; + const right = left + box.w * placement.scale; + const top = placement.top + box.y * placement.scale; + const bottom = top + box.h * placement.scale; + expect(left).toBeGreaterThanOrEqual(0); + expect(right).toBeLessThanOrEqual(viewport.width); + expect(top).toBeGreaterThanOrEqual(0); + expect(bottom).toBeLessThanOrEqual(viewport.height); + } + } + } + }); + + it("binds every Post FX title and control row to the shared geometry contract", () => { + const source = readFileSync( + new URL("../components/NAMRackDesignPort.tsx", import.meta.url), + "utf8", + ); + + expect(source).not.toMatch(/name="(?:modulator|delay|reverb)"[^>]*titleY=\{7\}/); + expect(source).toContain("titleY={postLayout.modules.modulator.titleY}"); + expect(source).toContain("titleY={postLayout.modules.delay.titleY}"); + expect(source).toContain("titleY={postLayout.modules.reverb.titleY}"); + expect(source).toContain("y={postLayout.modulator.footY}"); + expect(source).toContain("y={postLayout.delay.footY}"); + expect(source).toContain("y={postLayout.reverb.footY}"); + expect(source).toContain("size={postLayout.modulator.ledSize}"); + expect(source).toContain("size={postLayout.delay.ledSize}"); + expect(source).toContain("size={postLayout.reverb.ledSize}"); + expect(source).toContain("x={postLayout.modulator.primaryX}"); + expect(source).toContain("x={postLayout.delay.primaryX}"); + expect(source).toContain("x={postLayout.reverb.primaryX}"); + expect(source).toContain("size={postLayout.delay.secondaryFootSize}"); + expect(source).toContain("size={postLayout.delay.secondaryLedSize}"); + expect(source).toContain("size={postLayout.modulator.topKnobSize}"); + expect(source).toContain("size={postLayout.delay.lowerKnobSize}"); + expect(source).toContain("labelOffset={postLayout.reverb.lowerLabelOffset}"); + + const widePedalSource = source.match(/function WidePedal\([\s\S]*?\n}\n\nfunction TopShell/)?.[0] ?? ""; + expect(widePedalSource).not.toContain(" ({ + id, + label: id === "preEqHPFHz" ? "PRE HPF" : "PRE LPF", + type: "continuous", + value, + min: id === "preEqHPFHz" ? 0 : 3000, + max: id === "preEqHPFHz" ? 180 : 24000, + defaultValue: id === "preEqHPFHz" ? 0 : 24000, + unit: "Hz", + automatable: true, +}); + +describe("NAM Rack PRE EQ V19 state and control contract", () => { + it("uses logarithmic active travel with opposite six-percent OFF detents", () => { + expect(CURRENT_NAM_EFFECTS_DSP_VERSION).toBe(19); + const detent = NAM_GRAPHIC_EQ_FILTER_OFF_DETENT; + + expect(namGraphicEqFilterHzFromNormalized("preEqHPFHz", detent / 2)).toBe(0); + expect(namGraphicEqFilterHzFromNormalized("preEqHPFHz", detent)).toBeCloseTo(35, 6); + expect(namGraphicEqFilterHzFromNormalized("preEqHPFHz", 1)).toBeCloseTo(180, 6); + expect(namGraphicEqFilterHzFromNormalized("preEqHPFHz", (1 + detent) / 2)) + .toBeCloseTo(Math.sqrt(35 * 180), 6); + + expect(namGraphicEqFilterHzFromNormalized("preEqLPFHz", 0)).toBeCloseTo(3000, 6); + expect(namGraphicEqFilterHzFromNormalized("preEqLPFHz", 1 - detent)).toBeCloseTo(20000, 6); + expect(namGraphicEqFilterHzFromNormalized("preEqLPFHz", 1 - detent / 2)).toBe(24000); + expect(namGraphicEqFilterHzFromNormalized("preEqLPFHz", (1 - detent) / 2)) + .toBeCloseTo(Math.sqrt(3000 * 20000), 6); + + expect(namGraphicEqFilterNormalizedFromHz("preEqHPFHz", 0)).toBe(0); + expect(namGraphicEqFilterNormalizedFromHz("preEqHPFHz", 35)).toBeCloseTo(detent, 8); + expect(namGraphicEqFilterNormalizedFromHz("preEqLPFHz", 20000)).toBeCloseTo(1 - detent, 8); + expect(namGraphicEqFilterNormalizedFromHz("preEqLPFHz", 24000)).toBe(1); + }); + + it("uses the curved domain for range, wheel, and keyboard paths", () => { + const hpfOff = makeFilter("preEqHPFHz", 0); + const lpfOff = makeFilter("preEqLPFHz", 24000); + + expect(rangeInputMin(hpfOff)).toBe(0); + expect(rangeInputMax(hpfOff)).toBe(1); + expect(rangeInputValue(hpfOff)).toBe(0); + expect(rangeInputValue(lpfOff)).toBe(1); + expect(normalizeParamValue(makeFilter("preEqHPFHz", 35), 35)) + .toBeCloseTo(NAM_GRAPHIC_EQ_FILTER_OFF_DETENT, 8); + expect(paramValueFromRangeInput(hpfOff, NAM_GRAPHIC_EQ_FILTER_OFF_DETENT)).toBeCloseTo(35, 6); + expect(offsetParamValue(hpfOff, 0, 1)).toBe(35); + expect(offsetParamValue(hpfOff, 0, -1)).toBe(0); + expect(offsetParamValue(lpfOff, 24000, -1)).toBe(20000); + expect(offsetParamValue(lpfOff, 24000, 1)).toBe(24000); + expect(formatParamValue(hpfOff)).toBe("OFF"); + expect(formatParamValue(lpfOff)).toBe("OFF"); + }); + + it("updates private recall only for active cutoffs", () => { + expect(namGraphicEqActiveRecallUpdate("preEqHPFHz", 70)) + .toEqual(["preEqHPFLastActiveHz", 70]); + expect(namGraphicEqActiveRecallUpdate("preEqLPFHz", 12500)) + .toEqual(["preEqLPFLastActiveHz", 12500]); + expect(namGraphicEqActiveRecallUpdate("preEqHPFHz", 0)).toBeNull(); + expect(namGraphicEqActiveRecallUpdate("preEqLPFHz", 24000)).toBeNull(); + }); + + it("rejects pre-V16 collisions and migrates the V16-V18 seven-band layout", () => { + const legacy = migrateLegacyNAMRackPresetDspState({ + values: { + preEqEnabled: 1, + preEq100Db: 9, + preEqHPFHz: 120, + preEqLPFHz: 7000, + preEqHPFLastActiveHz: 140, + preEqLPFLastActiveHz: 6000, + }, + dspState: { namEffectsDspVersion: 15 }, + }, { completePreset: true }) as { values: Record }; + expect(legacy.values).toMatchObject({ + preEqEnabled: 0, + preEq120Db: 0, + preEq12kDb: 0, + preEqHPFHz: 0, + preEqLPFHz: 24000, + preEqHPFLastActiveHz: 80, + preEqLPFLastActiveHz: 12000, + }); + + expect(legacy.values).not.toHaveProperty("preEq100Db"); + + const migratedV18 = migrateLegacyNAMRackPresetDspState({ + values: { + preEqEnabled: 1, + preEq100Db: -3.5, + preEq200Db: -2, + preEq400Db: -1, + preEq800Db: 0.5, + preEq1k6Db: 1.5, + preEq3k2Db: 2.5, + preEq6k4Db: 3.5, + preEq12kDb: 11, + preEqHPFHz: 64, + preEqLPFHz: 13000, + preEqHPFLastActiveHz: 64, + preEqLPFLastActiveHz: 13000, + }, + dspState: { namEffectsDspVersion: 18 }, + }, { completePreset: true }) as { values: Record }; + expect(migratedV18.values).toMatchObject({ + preEqEnabled: 1, + preEq120Db: -3.5, + preEq250Db: -2, + preEq500Db: -1, + preEq1kDb: 0.5, + preEq2k5Db: 1.5, + preEq5kDb: 2.5, + preEq8kDb: 3.5, + preEq12kDb: 0, + preEqHPFHz: 64, + preEqLPFHz: 13000, + preEqHPFLastActiveHz: 64, + preEqLPFLastActiveHz: 13000, + }); + expect(migratedV18.values).not.toHaveProperty("preEq100Db"); + expect(migratedV18.values).not.toHaveProperty("preEq6k4Db"); + }); + + it("round-trips all eight current bands without treating V19 as legacy", () => { + const current = migrateLegacyNAMRackPresetDspState({ + values: { + preEqEnabled: 1, + preEq120Db: -4, + preEq250Db: -3, + preEq500Db: -2, + preEq1kDb: -1, + preEq2k5Db: 1, + preEq5kDb: 2, + preEq8kDb: 3, + preEq12kDb: 4, + preEqHPFHz: 0, + preEqLPFHz: 24000, + }, + dspState: { namEffectsDspVersion: 19 }, + }, { completePreset: true }) as { + values: Record; + dspState: Record; + }; + expect(current.values).toMatchObject({ + preEq120Db: -4, + preEq250Db: -3, + preEq500Db: -2, + preEq1kDb: -1, + preEq2k5Db: 1, + preEq5kDb: 2, + preEq8kDb: 3, + preEq12kDb: 4, + }); + expect(current.dspState.namEffectsDspVersion).toBe(19); + }); + + it("migrates baseline and Compare snapshots with the enclosing legacy version", () => { + const migrated = migrateLegacyNAMRackPresetDspState({ + values: { preEqEnabled: 1, preEq100Db: -2 }, + dspState: { namEffectsDspVersion: 18 }, + uiState: { + namPresetBaseline: { + values: { preEqEnabled: 1, preEq6k4Db: 4 }, + }, + namRackCompare: { + snapshots: { + A: { values: { preEq200Db: -5 } }, + B: { + values: { preEq12kDb: 6 }, + dspState: { namEffectsDspVersion: 19 }, + }, + }, + }, + }, + }, { completePreset: true }) as Record; + + expect(migrated.values).toMatchObject({ preEq120Db: -2, preEq12kDb: 0 }); + expect(migrated.uiState.namPresetBaseline.values).toMatchObject({ + preEq8kDb: 4, + preEq12kDb: 0, + }); + expect(migrated.uiState.namRackCompare.snapshots.A.values.preEq250Db).toBe(-5); + expect(migrated.uiState.namRackCompare.snapshots.B.values.preEq12kDb).toBe(6); + expect(migrated.uiState.namPresetBaseline.values).not.toHaveProperty("preEq6k4Db"); + }); + + it("hides retired band IDs from a mixed-version native schema", () => { + const parameters: BuiltInPluginSchema["parameters"] = [ + { + id: "preEq100Db", + label: "100 Hz", + type: "continuous", + value: 3, + min: -12, + max: 12, + defaultValue: 0, + }, + { + id: "preEq120Db", + label: "120 Hz", + type: "continuous", + value: 3, + min: -12, + max: 12, + defaultValue: 0, + }, + ]; + const projected = projectNAMRackSchemaForUI({ + schemaVersion: 1, + name: "OpenStudio NAM Rack", + category: "NAM", + chain: "track", + fxIndex: 0, + parameters, + modelState: { namEffectsDspVersion: 18 }, + }); + + expect(projected.parameters.map(({ id }) => id)).toEqual(["preEq120Db"]); + }); +}); diff --git a/frontend/src/__tests__/namPrecisionDriveGateControls.test.ts b/frontend/src/__tests__/namPrecisionDriveGateControls.test.ts new file mode 100644 index 0000000..0c3258a --- /dev/null +++ b/frontend/src/__tests__/namPrecisionDriveGateControls.test.ts @@ -0,0 +1,88 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + NAM_PEDAL_HARDWARE_STANDARD_PX, + NAM_PRECISION_DRIVE_FACEPLATE_LAYOUT, + NAM_PRECISION_DRIVE_GATE_KNOB_PX, + NAM_PRE_SIGNAL_LAYOUT, +} from "../components/NAMRackDesignPort"; + +const readSource = (relativePath: string) => + readFileSync(new URL(relativePath, import.meta.url), "utf8"); + +describe("NAM Rack Precision Drive Gate control", () => { + it("centres a compact Gate rotary among the four main controls", () => { + const layout = NAM_PRECISION_DRIVE_FACEPLATE_LAYOUT; + const box = NAM_PRE_SIGNAL_LAYOUT.precisionDrive; + const xPx = (percentage: number) => box.w * percentage / 100; + const yPx = (percentage: number) => box.h * percentage / 100; + + expect(box).toMatchObject({ w: 120, h: 232 }); + expect(xPx(layout.gate.x)).toBeCloseTo(60, 8); + expect(yPx(layout.gate.y)).toBeCloseTo(76.56, 8); + expect(layout.gate.x).toBe((layout.columns[0] + layout.columns[1]) / 2); + expect(xPx(layout.gate.size)).toBeCloseTo(NAM_PRECISION_DRIVE_GATE_KNOB_PX, 8); + expect(NAM_PRECISION_DRIVE_GATE_KNOB_PX).toBeLessThan( + NAM_PEDAL_HARDWARE_STANDARD_PX.knob, + ); + expect(xPx(layout.gate.hitSize)).toBeCloseTo(20, 8); + expect(xPx(layout.knobSize)).toBeCloseTo(28, 8); + expect(xPx(layout.knobHitSize)).toBeCloseTo(28, 8); + + for (const mainY of [layout.topY, layout.lowerY]) { + for (const mainX of layout.columns) { + const centreDistance = Math.hypot( + xPx(Math.abs(layout.gate.x - mainX)), + yPx(Math.abs(layout.gate.y - mainY)), + ); + expect(centreDistance).toBeGreaterThan((28 + NAM_PRECISION_DRIVE_GATE_KNOB_PX) / 2); + } + } + }); + + it("binds the active Design Port rotary to the existing drive-local Gate", () => { + const designSource = readSource( + "../components/NAMRackDesignPort.tsx", + ); + const parameterIndex = designSource.indexOf( + 'paramId="precisionDriveGate"', + ); + const controlStart = designSource.lastIndexOf( + "", parameterIndex); + const controlSource = designSource.slice(controlStart, controlEnd + 2); + + expect(parameterIndex).toBeGreaterThan(-1); + expect(controlStart).toBeGreaterThan(-1); + expect(controlSource).toContain('kind="black"'); + expect(controlSource).toContain('semanticLabel="Drive Gate"'); + expect(controlSource).toContain('labelText=""'); + expect(controlSource).toContain("driveLayout.gate.x"); + expect(controlSource).toContain("driveLayout.gate.y"); + expect(controlSource).toContain("driveLayout.gate.size"); + expect(controlSource).toContain("driveLayout.gate.hitSize"); + const driveStage = designSource.slice( + designSource.indexOf('box={NAM_PRE_SIGNAL_LAYOUT.precisionDrive}'), + designSource.indexOf("box={NAM_PRE_SIGNAL_LAYOUT.distortion}"), + ); + expect(driveStage).not.toContain("precisionDriveVoice"); + }); + + it("retains the same parameter across defaults, schema, and the active control surface", () => { + const panelSource = readSource("../components/NAMRackPanel.tsx"); + const bridgeSource = readSource("../services/NativeBridge.ts"); + const designSource = readSource("../components/NAMRackDesignPort.tsx"); + + expect(panelSource).toContain("precisionDriveGate: 0"); + expect(panelSource).toContain('"precisionDriveGate", "precisionDriveDrive"'); + expect(bridgeSource).toContain( + 'param("precisionDriveGate", "PD Gate", 0, 0, 1, "", "drive")', + ); + expect(designSource).toContain('paramId="precisionDriveGate"'); + expect(designSource).toContain('semanticLabel="Drive Gate"'); + }); +}); diff --git a/frontend/src/__tests__/namPrecisionDriveVoiceV15.test.ts b/frontend/src/__tests__/namPrecisionDriveVoiceV15.test.ts new file mode 100644 index 0000000..d02ed8e --- /dev/null +++ b/frontend/src/__tests__/namPrecisionDriveVoiceV15.test.ts @@ -0,0 +1,119 @@ +// @ts-expect-error Vitest provides Node builtins while the app tsconfig omits Node typings. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { createNAMBootSchema } from "../components/BuiltInPluginPanel"; +import { NAM_RACK_ADVANCED_CONTROL_IDS } from "../components/NAMRackMixer"; +import { + projectNAMRackSchemaForUI, + type BuiltInPluginSchema, +} from "../services/NativeBridge"; +import { + NAM_PRECISION_DRIVE_VOICE_LABELS, + normalizeNAMEffectsDspVersion, + normalizeNAMPrecisionDriveVoice, +} from "../utils/namPortableState"; +import { + CURRENT_NAM_EFFECTS_DSP_VERSION, + isCurrentNAMRackPresetState, + migrateLegacyNAMRackPresetDspState, +} from "../utils/namRackPresetTransactions"; + +type RackState = { + values: Record; + dspState: Record; + uiState?: Record; +}; + +function migrateComplete( + version: number | undefined, + voice: unknown, + uiState?: Record, +) { + return migrateLegacyNAMRackPresetDspState({ + values: { precisionDriveVoice: voice, precisionDriveDrive: 0.42 }, + dspState: version === undefined ? {} : { namEffectsDspVersion: version }, + uiState, + }, { completePreset: true }) as RackState; +} + +describe("NAM Rack retired Maxon selector V18 compatibility contract", () => { + it("loads old and current presets as Precision Drive while deleting the retired selector", () => { + expect(CURRENT_NAM_EFFECTS_DSP_VERSION).toBe(19); + for (const version of [1, 7, 14, 15, 16, 17, 18, 19, undefined, 0, 20, 999]) { + const migrated = migrateComplete(version, 1); + expect(migrated.values).not.toHaveProperty("precisionDriveVoice"); + expect(migrated.values.precisionDriveDrive).toBe(0.42); + expect(migrated.dspState.namEffectsDspVersion).toBe(19); + expect(isCurrentNAMRackPresetState(migrated)).toBe(true); + } + }); + + it("retains only the surviving Precision identity", () => { + expect(NAM_PRECISION_DRIVE_VOICE_LABELS).toEqual(["Precision"]); + for (const value of [-1, 0, 0.5, 1, 2, "1", Number.NaN, undefined]) { + expect(normalizeNAMPrecisionDriveVoice(value)).toBe(0); + } + for (const version of [14, 15, 16, 17, 18, 19]) { + expect(normalizeNAMEffectsDspVersion(version)).toBe(19); + } + expect(normalizeNAMEffectsDspVersion(20)).toBeUndefined(); + }); + + it("prunes the retired selector from baseline and Compare snapshots", () => { + const snapshot = { + values: { precisionDriveVoice: 1, precisionDriveDrive: 0.6 }, + dspState: { namEffectsDspVersion: 17, reverbEngineVersion: 5 }, + }; + const migrated = migrateComplete(17, 1, { + namPresetBaseline: snapshot, + namRackCompare: { snapshots: { A: snapshot, B: snapshot } }, + }); + + expect(migrated.values).not.toHaveProperty("precisionDriveVoice"); + expect(migrated.uiState?.namPresetBaseline.values).not.toHaveProperty("precisionDriveVoice"); + expect(migrated.uiState?.namRackCompare.snapshots.A.values).not.toHaveProperty("precisionDriveVoice"); + expect(migrated.uiState?.namRackCompare.snapshots.B.values).not.toHaveProperty("precisionDriveVoice"); + }); + + it("filters stale native schemas and omits the selector from boot schema", () => { + const rawVoice: BuiltInPluginSchema["parameters"][number] = { + id: "precisionDriveVoice", + label: "Legacy voice", + type: "enum", + value: 1, + min: 0, + max: 1, + defaultValue: 0, + automatable: true, + enumOptions: [{ value: 0, label: "Precision" }, { value: 1, label: "Maxon OD808" }], + }; + const projected = projectNAMRackSchemaForUI({ + schemaVersion: 1, + name: "OpenStudio NAM Rack", + category: "NAM", + chain: "track", + fxIndex: 0, + parameters: [rawVoice], + modelState: { namEffectsDspVersion: 17 }, + }); + expect(projected.parameters).toEqual([]); + + const bootSchema = createNAMBootSchema( + { chain: "track", fxIndex: 0 }, + "OpenStudio NAM Rack", + ); + expect(bootSchema.modelState?.namEffectsDspVersion).toBe(19); + expect(bootSchema.parameters.some((param) => param.id === "precisionDriveVoice")).toBe(false); + }); + + it("removes Maxon from active UI and keeps EQ Boost before the standalone Drive", () => { + const panel = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + const design = readFileSync(new URL("../components/NAMRackDesignPort.tsx", import.meta.url), "utf8"); + + expect(panel).not.toContain('paramById(params, "precisionDriveVoice")'); + expect(design).not.toContain("Maxon OD808"); + expect(NAM_RACK_ADVANCED_CONTROL_IDS["pre-eq"]).toContain("preEqEnabled"); + expect(NAM_RACK_ADVANCED_CONTROL_IDS["precision-drive"]).not.toContain("preEqEnabled"); + expect(NAM_RACK_ADVANCED_CONTROL_IDS["precision-drive"]).not.toContain("precisionDriveVoice"); + }); +}); diff --git a/frontend/src/__tests__/namPresetLibrary.test.ts b/frontend/src/__tests__/namPresetLibrary.test.ts new file mode 100644 index 0000000..6be7fb6 --- /dev/null +++ b/frontend/src/__tests__/namPresetLibrary.test.ts @@ -0,0 +1,135 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { + mutateStoredNAMPreset, + type NAMStoredPresetMutationBridge, +} from "../utils/namPresetLibrary"; + +function makeBridge( + copyResult: boolean | Error, + deleteResult: boolean | Error = true, +): NAMStoredPresetMutationBridge { + return { + copyBuiltInFXPreset: vi.fn(async () => { + if (copyResult instanceof Error) throw copyResult; + return copyResult; + }), + deleteBuiltInFXPreset: vi.fn(async () => { + if (deleteResult instanceof Error) throw deleteResult; + return deleteResult; + }), + }; +} + +describe("NAM stored preset library mutations", () => { + it("wires Duplicate/Rename to stored payload APIs rather than live rack recall", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + const handlersStart = panelSource.indexOf("const duplicateUserPreset"); + const handlersEnd = panelSource.indexOf("const togglePresetFavorite", handlersStart); + const handlers = panelSource.slice(handlersStart, handlersEnd); + + expect(handlersStart).toBeGreaterThan(-1); + expect(handlersEnd).toBeGreaterThan(handlersStart); + expect(handlers.match(/mutateStoredNAMPreset\(/g)).toHaveLength(2); + expect(handlers).not.toContain("loadBuiltInFXPreset"); + expect(handlers).not.toContain("saveBuiltInFXPreset"); + expect(handlers).not.toContain("setBuiltInPluginState"); + expect(handlers).toContain("could not be deleted. Both presets remain."); + }); + + it("duplicates the stored payload without deleting or recalling anything", async () => { + const bridge = makeBridge(true); + + const result = await mutateStoredNAMPreset( + bridge, + "OpenStudio NAM Rack", + "Studio Clean", + "Studio Clean Copy", + "duplicate", + ); + + expect(result).toEqual({ success: true, copied: true, sourceDeleted: false }); + expect(bridge.copyBuiltInFXPreset).toHaveBeenCalledWith( + "OpenStudio NAM Rack", + "Studio Clean", + "Studio Clean Copy", + ); + expect(bridge.deleteBuiltInFXPreset).not.toHaveBeenCalled(); + expect(Object.keys(bridge)).toEqual(["copyBuiltInFXPreset", "deleteBuiltInFXPreset"]); + }); + + it("does not delete the source when the renamed target cannot be saved", async () => { + const bridge = makeBridge(false); + + const result = await mutateStoredNAMPreset( + bridge, + "OpenStudio NAM Rack", + "Old Name", + "New Name", + "rename", + ); + + expect(result).toEqual({ + success: false, + copied: false, + sourceDeleted: false, + failure: "copy-failed", + }); + expect(bridge.deleteBuiltInFXPreset).not.toHaveBeenCalled(); + }); + + it("deletes the source only after the renamed target is saved", async () => { + const calls: string[] = []; + const bridge: NAMStoredPresetMutationBridge = { + copyBuiltInFXPreset: vi.fn(async () => { + calls.push("copy"); + return true; + }), + deleteBuiltInFXPreset: vi.fn(async () => { + calls.push("delete"); + return true; + }), + }; + + const result = await mutateStoredNAMPreset( + bridge, + "OpenStudio NAM Rack", + "Old Name", + "New Name", + "rename", + ); + + expect(calls).toEqual(["copy", "delete"]); + expect(result).toEqual({ success: true, copied: true, sourceDeleted: true }); + }); + + it.each([false, new Error("locked")])( + "surfaces source deletion failure after the target was saved (%s)", + async (deleteResult) => { + const bridge = makeBridge(true, deleteResult); + + const result = await mutateStoredNAMPreset( + bridge, + "OpenStudio NAM Rack", + "Old Name", + "New Name", + "rename", + ); + + expect(result).toEqual({ + success: false, + copied: true, + sourceDeleted: false, + failure: "delete-failed", + }); + expect(bridge.deleteBuiltInFXPreset).toHaveBeenCalledWith( + "OpenStudio NAM Rack", + "Old Name", + ); + }, + ); +}); diff --git a/frontend/src/__tests__/namPresetManagerTheme.test.ts b/frontend/src/__tests__/namPresetManagerTheme.test.ts new file mode 100644 index 0000000..262bb2e --- /dev/null +++ b/frontend/src/__tests__/namPresetManagerTheme.test.ts @@ -0,0 +1,76 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const css = readFileSync( + new URL("../components/NAMPresetManagerModal.css", import.meta.url), + "utf8", +); +const source = readFileSync( + new URL("../components/NAMPresetManagerModal.tsx", import.meta.url), + "utf8", +); + +function relativeLuminance(hex: string): number { + const channels = hex.replace("#", "").match(/.{2}/g)?.map((pair) => parseInt(pair, 16) / 255) ?? []; + const linear = channels.map((channel) => ( + channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4 + )); + return (0.2126 * (linear[0] ?? 0)) + + (0.7152 * (linear[1] ?? 0)) + + (0.0722 * (linear[2] ?? 0)); +} + +function contrastRatio(left: string, right: string): number { + const leftLuminance = relativeLuminance(left); + const rightLuminance = relativeLuminance(right); + return (Math.max(leftLuminance, rightLuminance) + 0.05) + / (Math.min(leftLuminance, rightLuminance) + 0.05); +} + +describe("NAM preset manager theme", () => { + it("uses the NAM Rack charcoal and warm amber palette without legacy blue accents", () => { + expect(source).toContain('import "./NAMPresetManagerModal.css"'); + expect(css).toContain("--nam-preset-bg: #090a0c"); + expect(css).toContain("--nam-preset-accent: #e0a149"); + expect(css).toContain("--nam-preset-accent-hot: #ffc36c"); + expect(css).toContain("rgba(224, 161, 73, 0.14)"); + + for (const legacyBlue of [ + "#55a8ed", + "#6db8f5", + "#56a9ee", + "#91d1ff", + "#348bd1", + "#236ba6", + "#4099df", + "#2878b9", + "rgba(62, 123, 178", + "rgba(59, 137, 208", + "rgba(53, 119, 178", + ]) { + expect(css.toLowerCase()).not.toContain(legacyBlue); + } + }); + + it("applies the warm accent to selection, focus, status, tags, and primary actions", () => { + expect(css).toContain('.nam-preset-library-row[data-selected="true"]'); + expect(css).toContain("box-shadow: inset 2px 0 0 var(--nam-preset-accent)"); + expect(css).toContain(".nam-preset-library-search:focus-within"); + expect(css).toContain("outline: 2px solid var(--nam-preset-accent-hot)"); + expect(css).toContain(".nam-preset-library-status"); + expect(css).toContain(".nam-preset-library-tags span"); + expect(css).toContain("background: linear-gradient(180deg, #efb45e, #c98231)"); + expect(source).toContain("data-selected={selected}"); + expect(source).toContain("data-active={entry.active}"); + }); + + it("keeps representative normal text pairs above WCAG AA contrast", () => { + expect(contrastRatio("#171006", "#c98231")).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio("#ffc36c", "#18191b")).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio("#928c83", "#171719")).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio("#dec28f", "#111214")).toBeGreaterThanOrEqual(4.5); + }); +}); diff --git a/frontend/src/__tests__/namPreviewMonitoring.test.ts b/frontend/src/__tests__/namPreviewMonitoring.test.ts new file mode 100644 index 0000000..db76063 --- /dev/null +++ b/frontend/src/__tests__/namPreviewMonitoring.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createNAMPreviewMonitorLease, + markTrackMonitorUserMutation, +} from "../utils/trackMonitorOwnership"; + +function monitorHarness(trackId: string, initiallyEnabled: boolean) { + let enabled = initiallyEnabled; + const setTransient = vi.fn(async (_trackId: string, next: boolean) => { + enabled = next; + return true; + }); + const lease = createNAMPreviewMonitorLease({ + read: (candidate) => candidate === trackId ? enabled : undefined, + setTransient, + }); + return { + lease, + setTransient, + read: () => enabled, + set: (next: boolean) => { enabled = next; }, + }; +} + +describe("NAM preview track-monitor ownership", () => { + it("temporarily enables an off track once across capture switches and restores it on Stop", async () => { + const harness = monitorHarness("monitor-stop", false); + + await expect(harness.lease.ensureEnabled("monitor-stop")).resolves.toBe(true); + await expect(harness.lease.ensureEnabled("monitor-stop")).resolves.toBe(true); + expect(harness.read()).toBe(true); + expect(harness.setTransient).toHaveBeenCalledTimes(1); + + await expect(harness.lease.release()).resolves.toBe(true); + expect(harness.read()).toBe(false); + expect(harness.setTransient).toHaveBeenLastCalledWith("monitor-stop", false); + }); + + it("does not claim or disable monitoring that was already on", async () => { + const harness = monitorHarness("monitor-existing", true); + + await expect(harness.lease.ensureEnabled("monitor-existing")).resolves.toBe(true); + await expect(harness.lease.release()).resolves.toBe(true); + expect(harness.setTransient).not.toHaveBeenCalled(); + expect(harness.read()).toBe(true); + }); + + it("never restores over an explicit user monitor change during audition", async () => { + const harness = monitorHarness("monitor-user-owned", false); + await harness.lease.ensureEnabled("monitor-user-owned"); + + markTrackMonitorUserMutation("monitor-user-owned"); + harness.set(false); + await expect(harness.lease.ensureEnabled("monitor-user-owned")).resolves.toBe(false); + markTrackMonitorUserMutation("monitor-user-owned"); + harness.set(true); + + await expect(harness.lease.release()).resolves.toBe(true); + expect(harness.read()).toBe(true); + expect(harness.setTransient).toHaveBeenCalledTimes(1); + }); + + it("relinquishes a raced enable to the user and propagates native failures", async () => { + let enabled = false; + let finish: ((value: boolean) => void) | undefined; + const pending = new Promise((resolve) => { finish = resolve; }); + const setTransient = vi.fn(async () => pending); + const lease = createNAMPreviewMonitorLease({ + read: () => enabled, + setTransient, + }); + + const enabling = lease.ensureEnabled("monitor-race"); + markTrackMonitorUserMutation("monitor-race"); + enabled = true; + finish?.(true); + await expect(enabling).resolves.toBe(true); + await expect(lease.release()).resolves.toBe(true); + expect(setTransient).toHaveBeenCalledTimes(1); + + const failed = createNAMPreviewMonitorLease({ + read: () => false, + setTransient: async () => false, + }); + await expect(failed.ensureEnabled("monitor-false")).resolves.toBe(false); + await expect(failed.release()).resolves.toBe(true); + + const rejected = createNAMPreviewMonitorLease({ + read: () => false, + setTransient: async () => { throw new Error("bridge unavailable"); }, + }); + await expect(rejected.ensureEnabled("monitor-reject")).rejects.toThrow("bridge unavailable"); + await expect(rejected.release()).resolves.toBe(true); + }); +}); diff --git a/frontend/src/__tests__/namPreviewRecovery.test.ts b/frontend/src/__tests__/namPreviewRecovery.test.ts new file mode 100644 index 0000000..b7b478d --- /dev/null +++ b/frontend/src/__tests__/namPreviewRecovery.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; + +import { provisionalNAMPreviewMatchesState } from "../components/NAMExplorer"; + +const baseline = { + pedalModelPath: "", + ampModelPath: "C:/NAM/Baseline.nam", + cabIRPath: "C:/IR/Baseline.wav", + pedalDeclaredCaptureType: "unknown", + ampDeclaredCaptureType: "full_rig", + cabEnabled: 1, + cabRequestedEnabled: true, + pedalMix: 0, + ampEnabled: 1, + ampMix: 1, + pedalCalibrationMode: 1, + pedalOverrideInputLevelDbu: 12, + pedalOverrideOutputLevelDbu: 12, + ampCalibrationMode: 1, + ampOverrideInputLevelDbu: 12, + ampOverrideOutputLevelDbu: 12, +}; + +function audition(overrides: Record = {}) { + const candidate = { + key: "amp:preview", + slot: "amp", + toneId: 1, + modelId: 2, + title: "Preview", + modelName: "Preview", + creator: "QA", + localPath: "C:/NAM/Preview.nam", + previousPath: baseline.ampModelPath, + source: "installed", + previewDownload: false, + saved: false, + action: "live-preview", + captureType: "amp", + includesCab: false, + baseline, + ...overrides, + } as any; + return { + ...candidate, + provisionalPublication: candidate.provisionalPublication ?? { + slot: candidate.slot, + localPath: candidate.localPath, + cabRequestedEnabled: candidate.slot === "cab" ? true : baseline.cabRequestedEnabled, + effectiveCabEnabled: candidate.slot === "cab" ? 1 : undefined, + pedalMix: candidate.slot === "pedal" ? 1 : undefined, + ampEnabled: candidate.slot === "amp" ? 1 : undefined, + ampMix: candidate.slot === "amp" ? 1 : undefined, + }, + } as any; +} + +describe("NAM provisional preview recovery guard", () => { + it("requires amp-only effective Cab state to match the preserved request", () => { + const state = { + modelState: { + ampModelPath: "c:\\nam\\preview.nam", + hasAmpModel: true, + cabRequestedEnabled: true, + }, + values: { auditionSource: 0, cabEnabled: 1, ampEnabled: 1, ampMix: 1 }, + }; + expect(provisionalNAMPreviewMatchesState(state, audition())).toBe(true); + expect(provisionalNAMPreviewMatchesState({ + ...state, + values: { ...state.values, cabEnabled: 0 }, + }, audition())).toBe(false); + }); + + it("ignores retired diagnostic-source state but rejects power, mix, or Cab changes", () => { + const state = { + modelState: { + ampModelPath: "C:/NAM/Preview.nam", + hasAmpModel: true, + cabRequestedEnabled: true, + }, + values: { auditionSource: 1, cabEnabled: 1, ampEnabled: 1, ampMix: 1 }, + }; + expect(provisionalNAMPreviewMatchesState(state, audition())).toBe(true); + expect(provisionalNAMPreviewMatchesState({ + ...state, + values: { auditionSource: 0, cabEnabled: 1, ampEnabled: 0, ampMix: 1 }, + }, audition())).toBe(false); + expect(provisionalNAMPreviewMatchesState({ + ...state, + values: { auditionSource: 0, cabEnabled: 1, ampEnabled: 1, ampMix: 0 }, + }, audition())).toBe(false); + expect(provisionalNAMPreviewMatchesState({ + ...state, + modelState: { ...state.modelState, cabRequestedEnabled: false }, + values: { auditionSource: 0, cabEnabled: 0, ampEnabled: 1, ampMix: 1 }, + }, audition())).toBe(false); + }); + + it("uses authoritative embedded-Cab topology for safety instead of a provisional effective-Cab guess", () => { + const fullRig = audition({ captureType: "full-rig", includesCab: true }); + expect(provisionalNAMPreviewMatchesState({ + modelState: { + ampModelPath: "C:/NAM/Preview.nam", + hasAmpModel: true, + ampIncludesCab: true, + cabRequestedEnabled: true, + }, + values: { auditionSource: 0, cabEnabled: 0, ampEnabled: 1, ampMix: 1 }, + }, fullRig)).toBe(true); + expect(provisionalNAMPreviewMatchesState({ + modelState: { + ampModelPath: "C:/NAM/Preview.nam", + hasAmpModel: true, + ampIncludesCab: true, + cabRequestedEnabled: true, + }, + values: { auditionSource: 0, cabEnabled: 1, ampEnabled: 1, ampMix: 1 }, + }, fullRig)).toBe(false); + }); + + it("rejects an effective Cab that is on while the durable request is off", () => { + const requestOff = audition({ + provisionalPublication: { + slot: "amp", + localPath: "C:/NAM/Preview.nam", + cabRequestedEnabled: false, + effectiveCabEnabled: 0, + ampEnabled: 1, + ampMix: 1, + }, + }); + const state = { + modelState: { + ampModelPath: "C:/NAM/Preview.nam", + hasAmpModel: true, + ampIncludesCab: false, + cabRequestedEnabled: false, + }, + values: { auditionSource: 0, cabEnabled: 0, ampEnabled: 1, ampMix: 1 }, + }; + expect(provisionalNAMPreviewMatchesState(state, requestOff)).toBe(true); + expect(provisionalNAMPreviewMatchesState({ + ...state, + values: { ...state.values, cabEnabled: 1 }, + }, requestOff)).toBe(false); + }); + + it("accepts only the still-enabled matching Cab/IR preview", () => { + const cab = audition({ + key: "cab:preview", + slot: "cab", + localPath: "C:/IR/Preview.wav", + previousPath: baseline.cabIRPath, + captureType: "unknown", + }); + expect(provisionalNAMPreviewMatchesState({ + modelState: { cabIRPath: "C:/IR/Preview.wav", hasCabIR: true, cabRequestedEnabled: true }, + values: { auditionSource: 0, cabEnabled: 1 }, + }, cab)).toBe(true); + expect(provisionalNAMPreviewMatchesState({ + modelState: { cabIRPath: "C:/IR/Preview.wav", hasCabIR: true, cabRequestedEnabled: true }, + values: { auditionSource: 0, cabEnabled: 0 }, + }, cab)).toBe(false); + }); + +}); diff --git a/frontend/src/__tests__/namProjectStateIntegrity.test.ts b/frontend/src/__tests__/namProjectStateIntegrity.test.ts new file mode 100644 index 0000000..3665b86 --- /dev/null +++ b/frontend/src/__tests__/namProjectStateIntegrity.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import projectActionsSource from "../store/actions/project.ts?raw"; +import audioEngineHeaderSource from "../../../Source/AudioEngine.h?raw"; +import mainComponentSource from "../../../Source/MainComponent.cpp?raw"; +import { + collectNAMProjectAssetReferences, + summarizeNAMProjectStateIssues, +} from "../utils/namProjectState"; + +describe("NAM project-state integrity", () => { + it("discovers direct and A/B resources for a master NAM rack address", () => { + const assets = collectNAMProjectAssetReferences({ + modelState: { + pedalModelPath: "D:/NAM/drive.nam", + pedalDeclaredCaptureType: "pedal", + ampModelPath: "D:/NAM/amp.nam", + ampDeclaredCaptureType: "full_rig", + ampCaptureType: "amp_cab", + cabIRPath: "D:/IR/cab.wav", + }, + uiState: { + namRackCompare: { + snapshots: { + A: { modelState: { ampModelPath: "D:/NAM/a.nam" } }, + B: { modelState: { cabIRPath: "D:/IR/b.wav" } }, + }, + }, + }, + }, { + trackId: "", + trackName: "Master", + chain: "master", + fxIndex: 2, + pluginName: "OpenStudio NAM Rack", + }); + + expect(assets).toHaveLength(5); + expect(assets).toContainEqual(expect.objectContaining({ + chain: "master", + fxIndex: 2, + slot: "amp", + path: "D:/NAM/amp.nam", + captureType: "amp_cab", + gearType: "amp_cab", + })); + expect(assets).toContainEqual(expect.objectContaining({ + chain: "master", + compareSlot: "A", + compareSnapshot: true, + slot: "amp", + path: "D:/NAM/a.nam", + })); + expect(assets).toContainEqual(expect.objectContaining({ + chain: "master", + compareSlot: "B", + compareSnapshot: true, + slot: "cab", + path: "D:/IR/b.wav", + })); + }); + + it("builds a bounded, user-visible summary for rejected NAM restores", () => { + const summary = summarizeNAMProjectStateIssues([ + { phase: "restore", location: "Guitar / Track FX 1", detail: "saved NAM state was rejected" }, + { phase: "remove", location: "Master FX NAM Rack", detail: "native removal failed" }, + { phase: "add", location: "Bus / Input FX 2", detail: "NAM Rack could not be added" }, + { phase: "restore", location: "Master / FX 3", detail: "saved NAM state was rejected" }, + ]); + + expect(summary).toContain("4 NAM Rack project-state issues"); + expect(summary).toContain("Guitar / Track FX 1"); + expect(summary).toContain("plus 1 more"); + expect(summary).not.toContain("Master / FX 3"); + }); + + it("wires master asset discovery and checks every NAM restore result", () => { + expect(projectActionsSource).toContain('trackName: "Master"'); + expect(projectActionsSource).toContain('chain: "master"'); + expect(projectActionsSource).toContain("rawNAMAssets.push(...collectNAMAssetsFromPluginState"); + expect(projectActionsSource.match(/if \(isNAMRack && !stateResult\)/g)).toHaveLength(3); + expect(projectActionsSource).toContain("summarizeNAMProjectStateIssues(namProjectStateIssues)"); + expect(projectActionsSource).toContain("Project loaded with ${missingNAMAssets.length} missing NAM resource file"); + }); + + it("returns native removal truth instead of unconditional bridge success", () => { + expect(audioEngineHeaderSource).toContain("bool removeTrackInputFX"); + expect(audioEngineHeaderSource).toContain("bool removeTrackFX"); + expect(audioEngineHeaderSource).toContain("bool removeMasterFX"); + expect(mainComponentSource).toContain("completion(audioEngine.removeTrackInputFX"); + expect(mainComponentSource).toContain("completion(audioEngine.removeTrackFX"); + expect(mainComponentSource).toContain("completion(audioEngine.removeMasterFX"); + }); +}); diff --git a/frontend/src/__tests__/namRackAmpCaptureLibrary.test.ts b/frontend/src/__tests__/namRackAmpCaptureLibrary.test.ts new file mode 100644 index 0000000..fce3ce6 --- /dev/null +++ b/frontend/src/__tests__/namRackAmpCaptureLibrary.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + rankNAMRackAmpCaptures, + rankNAMRackCabIRs, +} from "../utils/namRackAmpCaptureLibrary"; + +describe("NAM Rack amp capture rail ranking", () => { + it("keeps the active capture first and then ranks captures by saved-preset usage", () => { + const captures = [ + { localPath: "C:\\NAM\\Clean.nam", name: "Clean" }, + { localPath: "C:\\NAM\\Heavy.nam", name: "Heavy" }, + { localPath: "C:\\NAM\\Lead.nam", name: "Lead" }, + ]; + const ranked = rankNAMRackAmpCaptures( + captures, + ["c:/nam/heavy.nam", "C:\\NAM\\Heavy.nam", "C:\\NAM\\Clean.nam"], + "C:\\NAM\\Lead.nam", + ); + + expect(ranked.map((entry) => entry.name)).toEqual(["Lead", "Heavy", "Clean"]); + expect(ranked.map((entry) => entry.presetUsageCount)).toEqual([0, 2, 1]); + expect(ranked[0].active).toBe(true); + }); + + it("filters non-amp library records and uses stable tie-breakers", () => { + const ranked = rankNAMRackAmpCaptures([ + { localPath: "z.nam", name: "Zulu", gearType: "amp", favorite: false, installedAt: "2025-01-01" }, + { localPath: "a.nam", name: "Alpha", gearType: "full rig", favorite: true, installedAt: "2024-01-01" }, + { localPath: "cab.wav", name: "Cab", gearType: "ir", favorite: true }, + ], [], undefined); + + expect(ranked.map((entry) => entry.name)).toEqual(["Alpha", "Zulu"]); + }); + + it("merges manifest and persistent IR history without duplicate paths", () => { + const ranked = rankNAMRackCabIRs([ + { localPath: "C:\\IRs\\Mesa.wav", name: "Mesa", gearType: "cabinet ir" }, + { localPath: "c:/irs/mesa.wav", favorite: true, gearType: "ir", lastUsed: 20 }, + { localPath: "C:\\IRs\\Orange.flac", name: "Orange", lastUsed: 10 }, + { localPath: "C:\\NAM\\Amp.nam", name: "Amp", gearType: "amp" }, + ], "C:\\IRs\\Orange.flac"); + + expect(ranked).toHaveLength(2); + expect(ranked.map((entry) => entry.name)).toEqual(["Orange", "Mesa"]); + expect(ranked[0].active).toBe(true); + expect(ranked[1].favorite).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/namRackControlTooltip.test.ts b/frontend/src/__tests__/namRackControlTooltip.test.ts new file mode 100644 index 0000000..e817c46 --- /dev/null +++ b/frontend/src/__tests__/namRackControlTooltip.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { positionNAMRackTooltip } from "../components/NAMRackControlTooltip"; + +const viewport = { top: 0, left: 0, width: 800, height: 600 }; +const tooltip = { width: 120, height: 48 }; + +describe("NAM rack control tooltip placement", () => { + it("prefers above a control when there is room", () => { + expect(positionNAMRackTooltip( + { top: 300, right: 440, bottom: 340, left: 400, width: 40, height: 40 }, + tooltip, + viewport, + )).toEqual({ left: 360, top: 242, placement: "above" }); + }); + + it("flips below controls near the top of the viewport", () => { + expect(positionNAMRackTooltip( + { top: 12, right: 440, bottom: 52, left: 400, width: 40, height: 40 }, + tooltip, + viewport, + )).toEqual({ left: 360, top: 62, placement: "below" }); + }); + + it("keeps the tooltip inside both horizontal viewport edges", () => { + const atLeft = positionNAMRackTooltip( + { top: 300, right: 30, bottom: 340, left: -10, width: 40, height: 40 }, + tooltip, + viewport, + ); + const atRight = positionNAMRackTooltip( + { top: 300, right: 810, bottom: 340, left: 770, width: 40, height: 40 }, + tooltip, + viewport, + ); + + expect(atLeft.left).toBe(8); + expect(atRight.left).toBe(672); + }); + + it("honours an offset visual viewport and clamps oversized vertical placement", () => { + expect(positionNAMRackTooltip( + { top: 115, right: 160, bottom: 145, left: 130, width: 30, height: 30 }, + { width: 130, height: 190 }, + { top: 100, left: 50, width: 300, height: 200 }, + )).toEqual({ left: 80, top: 108, placement: "below" }); + }); +}); diff --git a/frontend/src/__tests__/namRackFaceplateGeometry.test.ts b/frontend/src/__tests__/namRackFaceplateGeometry.test.ts new file mode 100644 index 0000000..92c8133 --- /dev/null +++ b/frontend/src/__tests__/namRackFaceplateGeometry.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; + +import { + createAmpV4FaceplateManifest, + faceplateControlHitRect, + faceplateControlVisualRect, + fitIntrinsicCanvas, + NAM_AMP_V4_FACEPLATE, + NAM_AMP_V4_GENERATION_SIZE, + NAM_AMP_V4_REFERENCE_ALPHA, + NAM_AMP_V4_REFERENCE_SIZE, + NAM_EQ_V4_FACEPLATE, + NAM_EQ_V4_FADER_CAP_CROP, + NAM_EQ_V4_FADER_CENTERS, + projectFaceplateManifest, + rectContainsRect, + scaleRectBetweenCanvases, + validateFaceplateManifest, + type FaceplateManifest, + type IntrinsicRect, +} from "../components/namRackFaceplateGeometry"; + +const sorted = (values: Iterable) => [...values].sort(); + +const AMP_PARAMS = [ + "ampEnabled", + "ampGainDb", + "ampBoost", + "ampVoice", + "bassDb", + "midDb", + "trebleDb", + "presenceDb", + "ampMix", + "ampOutputDb", +] as const; + +const EQ_PARAMS = [ + "eqEnabled", + "eqHPFHz", + "eq65Db", + "eq125Db", + "eq250Db", + "eq500Db", + "eq1kDb", + "eq2kDb", + "eq4kDb", + "eq8kDb", + "eq16kDb", + "eqLPFHz", + "eqLevelDb", +] as const; + +const uniqueParamIds = (manifest: FaceplateManifest) => + sorted(new Set(manifest.controls.map(({ paramId }) => paramId))); + +describe("NAM Rack intrinsic faceplate geometry", () => { + it("keeps every visible and interactive footprint inside painted alpha", () => { + for (const manifest of [NAM_AMP_V4_FACEPLATE, NAM_EQ_V4_FACEPLATE]) { + expect(validateFaceplateManifest(manifest)).toEqual([]); + for (const control of manifest.controls) { + expect( + rectContainsRect( + manifest.visibleAlphaBounds, + faceplateControlVisualRect(control), + ), + `${manifest.id}:${control.id}:visual`, + ).toBe(true); + expect( + rectContainsRect( + manifest.visibleAlphaBounds, + faceplateControlHitRect(control), + ), + `${manifest.id}:${control.id}:hit`, + ).toBe(true); + } + } + }); + + it("preserves all public Amp and Graphic EQ parameters", () => { + expect(uniqueParamIds(NAM_AMP_V4_FACEPLATE)).toEqual(sorted(AMP_PARAMS)); + expect(uniqueParamIds(NAM_EQ_V4_FACEPLATE)).toEqual(sorted(EQ_PARAMS)); + expect( + NAM_EQ_V4_FACEPLATE.controls.filter(({ paramId }) => paramId === "eqEnabled"), + ).toHaveLength(2); + for (const [toggleId, ledId, paramId] of [ + ["amp-power", "amp-power-led", "ampEnabled"], + ["amp-tight", "amp-tight-led", "ampBoost"], + ["amp-bright", "amp-bright-led", "ampVoice"], + ] as const) { + const toggle = NAM_AMP_V4_FACEPLATE.controls.find(({ id }) => id === toggleId); + const led = NAM_AMP_V4_FACEPLATE.controls.find(({ id }) => id === ledId); + expect(toggle).toMatchObject({ kind: "toggle", paramId }); + expect(led).toMatchObject({ kind: "led", paramId }); + if (toggle?.kind === "toggle" && led?.kind === "led") { + expect(led.center.x).toBeCloseTo(toggle.center.x, 8); + expect(led.center.y + led.visualDiameter / 2) + .toBeLessThan(toggle.center.y - toggle.visualDiameter / 2); + } + expect( + NAM_AMP_V4_FACEPLATE.controls.filter((control) => control.paramId === paramId), + ).toHaveLength(2); + } + }); + + it("maps the Amp template to the 1811 x 868 generation body without frozen old-asset pixels", () => { + const generatedAlpha = scaleRectBetweenCanvases( + NAM_AMP_V4_REFERENCE_ALPHA, + NAM_AMP_V4_REFERENCE_SIZE, + NAM_AMP_V4_GENERATION_SIZE, + ); + const generationManifest = createAmpV4FaceplateManifest({ + assetSize: NAM_AMP_V4_GENERATION_SIZE, + visibleAlphaBounds: generatedAlpha, + }); + + expect(generationManifest.assetSize).toEqual({ width: 1811, height: 868 }); + expect(validateFaceplateManifest(generationManifest)).toEqual([]); + generationManifest.controls.forEach((control, index) => { + const reference = NAM_AMP_V4_FACEPLATE.controls[index]; + expect(faceplateControlVisualRect(control).width).toBeCloseTo( + faceplateControlVisualRect(reference).width * 1811 / 2160, + 6, + ); + if (control.kind !== "fader" && reference.kind !== "fader") { + expect(control.center.x / 1811).toBeCloseTo(reference.center.x / 2160, 3); + expect(control.center.y / 868).toBeCloseTo(reference.center.y / 1035, 3); + } + }); + }); + + it("defines evenly spaced baked EQ wells and a truly cropped cap", () => { + const faders = NAM_EQ_V4_FACEPLATE.controls.filter( + (control) => control.kind === "fader", + ); + expect(faders).toHaveLength(9); + expect(faders.map(({ centerX }) => centerX)).toEqual(NAM_EQ_V4_FADER_CENTERS); + + const gaps = NAM_EQ_V4_FADER_CENTERS.slice(1).map( + (center, index) => center - NAM_EQ_V4_FADER_CENTERS[index], + ); + gaps.forEach((gap) => expect(gap).toBeCloseTo(141.25, 8)); + faders.forEach((fader) => { + expect(fader.bakedWell).toMatchObject({ y: 128, width: 30, height: 292 }); + expect(fader.capTravel).toEqual({ top: 144, bottom: 404 }); + expect(fader.capSize).toEqual({ width: 54, height: 28 }); + expect(fader.hitRect).toMatchObject({ y: 112, width: 112, height: 328 }); + expect(fader.bakedWell.height / 200).toBeGreaterThan(1.4); + expect((fader.capTravel.bottom - fader.capTravel.top) / 168).toBeGreaterThan(1.5); + }); + + expect(NAM_EQ_V4_FADER_CAP_CROP.measuredAlphaBounds).toEqual({ + x: 50, + y: 147, + width: 411, + height: 214, + }); + expect(NAM_EQ_V4_FADER_CAP_CROP.paddedCrop).toEqual({ + x: 42, + y: 139, + width: 427, + height: 230, + }); + expect( + rectContainsRect( + NAM_EQ_V4_FADER_CAP_CROP.paddedCrop, + NAM_EQ_V4_FADER_CAP_CROP.measuredAlphaBounds, + ), + ).toBe(true); + }); + + it("keeps the V6 lower-deck controls on their approved simplified geometry", () => { + expect(NAM_EQ_V4_FACEPLATE.safeZones.mainControls).toEqual({ + x: 156, + y: 110, + width: 1848, + height: 340, + }); + expect(NAM_EQ_V4_FACEPLATE.safeZones.utilityControls).toEqual({ + x: 156, + y: 460, + width: 1848, + height: 156, + }); + expect(NAM_EQ_V4_FACEPLATE.controls.find(({ id }) => id === "eq-hpf")) + .toMatchObject({ center: { x: 290, y: 274 } }); + expect(NAM_EQ_V4_FACEPLATE.controls.find(({ id }) => id === "eq-lpf")) + .toMatchObject({ center: { x: 1870, y: 274 } }); + expect(NAM_EQ_V4_FACEPLATE.controls.find(({ id }) => id === "eq-power")) + .toMatchObject({ center: { x: 290, y: 525 } }); + expect(NAM_EQ_V4_FACEPLATE.controls.find(({ id }) => id === "eq-led")) + .toMatchObject({ center: { x: 400, y: 525 } }); + expect(NAM_EQ_V4_FACEPLATE.controls.find(({ id }) => id === "eq-level")) + .toMatchObject({ + center: { x: 1870, y: 525 }, + visualDiameter: 90, + hitDiameter: 116, + }); + }); + + it("projects the same alpha-safe geometry at every supported host shape", () => { + const viewports: IntrinsicRect[] = [ + { x: 0, y: 0, width: 768, height: 341 }, + { x: 0, y: 0, width: 960, height: 540 }, + { x: 0, y: 0, width: 1280, height: 720 }, + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 17, y: 31, width: 2560, height: 1080 }, + ]; + + for (const manifest of [NAM_AMP_V4_FACEPLATE, NAM_EQ_V4_FACEPLATE]) { + for (const viewport of viewports) { + const canvas = fitIntrinsicCanvas(viewport, manifest.assetSize); + const projected = projectFaceplateManifest(manifest, canvas); + expect(rectContainsRect(canvas, projected.visibleAlpha)).toBe(true); + for (const visual of Object.values(projected.controlVisuals)) { + expect(rectContainsRect(projected.visibleAlpha, visual)).toBe(true); + } + for (const hit of Object.values(projected.controlHits)) { + expect(rectContainsRect(projected.visibleAlpha, hit)).toBe(true); + } + } + } + }); +}); diff --git a/frontend/src/__tests__/namRackFeedbackPresentationContract.test.ts b/frontend/src/__tests__/namRackFeedbackPresentationContract.test.ts new file mode 100644 index 0000000..ad0bd89 --- /dev/null +++ b/frontend/src/__tests__/namRackFeedbackPresentationContract.test.ts @@ -0,0 +1,59 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const readSource = (relativePath: string) => + readFileSync(new URL(relativePath, import.meta.url), "utf8"); + +describe("NAM Rack feedback presentation contract", () => { + it("consumes native embedded identity fields without changing the approved faceplate assets", () => { + const bridge = readSource("../services/NativeBridge.ts"); + const panel = readSource("../components/NAMRackPanel.tsx"); + + for (const field of [ + "pedalMetadataName", + "pedalMetadataGearMake", + "pedalMetadataGearModel", + "ampMetadataName", + "ampMetadataGearMake", + "ampMetadataGearModel", + ]) { + expect(bridge).toContain(`${field}?: string`); + expect(panel).toContain(`modelState?.${field}`); + } + expect(panel).toContain("resolveNAMModelIdentityWarning"); + expect(panel).toContain("effectiveRackDiagnosticMessage"); + }); + + it("removes the duplicate Tape Echo surface while preserving post-FX Delay", () => { + const panel = readSource("../components/NAMRackPanel.tsx"); + const designPort = readSource("../components/NAMRackDesignPort.tsx"); + const bridge = readSource("../services/NativeBridge.ts"); + + expect(panel).not.toContain("tapeEchoEnabled"); + expect(designPort).not.toContain('name="tape-echo"'); + expect(bridge).not.toContain('param("tapeEchoEnabled"'); + expect(bridge).toContain('param("delayMode", "Delay Mode"'); + expect(designPort).toContain('paramId="delayMode"'); + }); + + it("uses the existing compact stage-status lane for gain and Economy guidance", () => { + const panel = readSource("../components/NAMRackPanel.tsx"); + const designPort = readSource("../components/NAMRackDesignPort.tsx"); + const feedback = readSource("../utils/namRackPresentationFeedback.ts"); + + expect(panel).toContain("resolveNAMGainStagingWarning"); + expect(panel).toContain("resolveNAMEconomyQualityWarning"); + expect(feedback).toContain("Set Amp Quality to Full"); + expect(designPort).toContain('className="premium-stage-status"'); + }); + + it("labels a retained external IR as bypassed while the embedded cab is active", () => { + const panel = readSource("../components/NAMRackPanel.tsx"); + const designPort = readSource("../components/NAMRackDesignPort.tsx"); + + expect(panel).toContain("Cab in Capture / External IR bypassed"); + expect(panel).toContain('"Retained IR bypassed"'); + expect(designPort).toContain("CAB INCLUDED / IR BYPASSED"); + }); +}); diff --git a/frontend/src/__tests__/namRackMutationReadback.test.ts b/frontend/src/__tests__/namRackMutationReadback.test.ts new file mode 100644 index 0000000..19fa6b0 --- /dev/null +++ b/frontend/src/__tests__/namRackMutationReadback.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { vi } from "vitest"; +import { + applyVerifiedNAMRackMutation, + doesNAMRackMutationMatchReadback, +} from "../utils/namRackMutationReadback"; + +describe("NAM Rack mutation readback", () => { + it("verifies scalar updates and normalized resource paths", () => { + expect(doesNAMRackMutationMatchReadback({ + values: { cabEnabled: 1, ampMix: 0.5 }, + modelState: { cabIRPath: "C:/IRs/Studio.wav", hasCabIR: true }, + }, { + values: { cabEnabled: 1, ampMix: 0.5 }, + modelState: { cabIRPath: "c:\\irs\\studio.wav" }, + }, "windows")).toBe(true); + }); + + it("preserves case-sensitive resource identity on macOS and Linux", () => { + const state = { + modelState: { cabIRPath: "/IR/A.wav", hasCabIR: true }, + }; + const patch = { modelState: { cabIRPath: "/IR/a.wav" } }; + + expect(doesNAMRackMutationMatchReadback(state, patch, "linux")).toBe(false); + expect(doesNAMRackMutationMatchReadback(state, patch, "macos")).toBe(false); + }); + + it("rejects false-positive writes and verifies explicit clears", () => { + expect(doesNAMRackMutationMatchReadback({ + values: { cabEnabled: 0.5 }, + modelState: { cabIRPath: "C:/IRs/old.wav", hasCabIR: true }, + }, { + values: { cabEnabled: 0 }, + modelState: { clearCabIR: true }, + })).toBe(false); + + expect(doesNAMRackMutationMatchReadback({ + values: { cabEnabled: 0 }, + modelState: { cabIRPath: "", hasCabIR: false }, + }, { + values: { cabEnabled: 0 }, + modelState: { clearCabIR: true }, + })).toBe(true); + }); + + it("reports rejected, unverified, verified, and rejected-promise bridge paths", async () => { + const patch = { values: { cabEnabled: 0 }, modelState: { clearCabIR: true } }; + const bridge = { + setBuiltInPluginState: vi.fn(async () => false), + getBuiltInPluginState: vi.fn(async () => ({ + values: { cabEnabled: 0 }, + modelState: { cabIRPath: "", hasCabIR: false }, + })), + }; + + await expect(applyVerifiedNAMRackMutation(bridge, {}, patch)).resolves.toBe("rejected"); + expect(bridge.getBuiltInPluginState).not.toHaveBeenCalled(); + + bridge.setBuiltInPluginState.mockResolvedValueOnce(true); + bridge.getBuiltInPluginState.mockResolvedValueOnce({ + values: { cabEnabled: 1 }, + modelState: { cabIRPath: "old.wav", hasCabIR: true }, + }); + await expect(applyVerifiedNAMRackMutation(bridge, {}, patch)).resolves.toBe("unverified"); + + bridge.setBuiltInPluginState.mockResolvedValueOnce(true); + bridge.getBuiltInPluginState.mockResolvedValueOnce({ + values: { cabEnabled: 0 }, + modelState: { cabIRPath: "", hasCabIR: false }, + }); + await expect(applyVerifiedNAMRackMutation(bridge, {}, patch)).resolves.toBe("verified"); + + bridge.setBuiltInPluginState.mockRejectedValueOnce(new Error("native bridge failed")); + await expect(applyVerifiedNAMRackMutation(bridge, {}, patch)).rejects.toThrow("native bridge failed"); + }); +}); diff --git a/frontend/src/__tests__/namRackOrderPersistence.test.ts b/frontend/src/__tests__/namRackOrderPersistence.test.ts new file mode 100644 index 0000000..fdd7c2e --- /dev/null +++ b/frontend/src/__tests__/namRackOrderPersistence.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { persistOptimisticNAMRackOrder } from "../utils/namRackOrderPersistence"; + +describe("NAM Rack post-FX order persistence", () => { + it("keeps an optimistic order after native persistence succeeds", async () => { + const applied: string[][] = []; + const result = await persistOptimisticNAMRackOrder({ + previousOrder: ["eq", "mod", "delay", "reverb"], + nextOrder: ["delay", "eq", "mod", "reverb"], + applyOrder: (order) => applied.push(order), + persistOrder: vi.fn().mockResolvedValue(true), + }); + + expect(result).toEqual({ ok: true }); + expect(applied).toEqual([["delay", "eq", "mod", "reverb"]]); + }); + + it("restores the exact previous order and reports a useful error on false", async () => { + const applied: string[][] = []; + const result = await persistOptimisticNAMRackOrder({ + previousOrder: ["eq", "mod", "delay", "reverb"], + nextOrder: ["reverb", "delay", "mod", "eq"], + applyOrder: (order) => applied.push(order), + persistOrder: vi.fn().mockResolvedValue(false), + }); + + expect(result.ok).toBe(false); + expect(result.errorMessage).toContain("previous order was restored"); + expect(applied).toEqual([ + ["reverb", "delay", "mod", "eq"], + ["eq", "mod", "delay", "reverb"], + ]); + }); + + it("rolls back when the bridge rejects", async () => { + const applied: string[][] = []; + const result = await persistOptimisticNAMRackOrder({ + previousOrder: ["eq", "mod"], + nextOrder: ["mod", "eq"], + applyOrder: (order) => applied.push(order), + persistOrder: vi.fn().mockRejectedValue(new Error("bridge disconnected")), + }); + + expect(result.ok).toBe(false); + expect(applied[applied.length - 1]).toEqual(["eq", "mod"]); + }); +}); diff --git a/frontend/src/__tests__/namRackPresentationFeedback.test.ts b/frontend/src/__tests__/namRackPresentationFeedback.test.ts new file mode 100644 index 0000000..16fdfe7 --- /dev/null +++ b/frontend/src/__tests__/namRackPresentationFeedback.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveNAMEconomyQualityWarning, + resolveNAMGainStagingWarning, + resolveNAMModelIdentityWarning, +} from "../utils/namRackPresentationFeedback"; + +describe("NAM Rack presentation feedback", () => { + it("flags a clear contradiction between the NAM label and embedded gear metadata", () => { + expect(resolveNAMModelIdentityWarning({ + displayName: "Peavey 5150", + metadataName: "Peavey 5150", + gearMake: "Victory V30 The Countess", + gearModel: "Victory V30 The Countess", + })).toBe( + "Embedded gear metadata identifies Victory V30 The Countess; the model label says “Peavey 5150”. Verify this Capture's identity.", + ); + }); + + it("reads nested preview metadata and stays quiet for matching or missing identity", () => { + expect(resolveNAMModelIdentityWarning({ + displayName: "Countess V30 full rig", + metadataSources: [{ namMetadata: { gear_make: "Victory", gear_model: "V30 The Countess" } }], + })).toBeNull(); + expect(resolveNAMModelIdentityWarning({ displayName: "Peavey 5150" })).toBeNull(); + }); + + it("treats a shared model number as an abbreviated match", () => { + expect(resolveNAMModelIdentityWarning({ + metadataName: "5150 Lead", + gearMake: "Peavey", + gearModel: "5150", + })).toBeNull(); + }); + + it("still catches a conflicting visible listing when the internal NAM name matches", () => { + expect(resolveNAMModelIdentityWarning({ + displayName: "Peavey 5150", + metadataName: "Victory V30 The Countess", + gearMake: "Victory", + gearModel: "V30 The Countess", + })).toContain('model label says “Peavey 5150”'); + }); + + it("warns for the selected Victory preset's stacked Input, Drive Level, and Tight Boost", () => { + expect(resolveNAMGainStagingWarning({ + inputTrimDb: 1.92, + driveActive: true, + driveLevelDb: 6, + ampActive: true, + ampBoostActive: true, + })).toContain("Input +1.9 dB, Drive Level +6.0 dB, Tight Boost"); + }); + + it("does not count bypassed stages or warn for neutral gain staging", () => { + expect(resolveNAMGainStagingWarning({ + inputTrimDb: 1.92, + driveActive: false, + driveLevelDb: 12, + ampActive: true, + ampBoostActive: false, + })).toBeNull(); + expect(resolveNAMGainStagingWarning({ + inputTrimDb: 12, + driveActive: true, + driveLevelDb: 12, + ampActive: false, + ampBoostActive: true, + })).toBeNull(); + }); + + it("does not suggest disabling Tight Boost when Tight Boost is already off", () => { + const warning = resolveNAMGainStagingWarning({ + inputTrimDb: 7, + driveActive: false, + ampActive: true, + ampBoostActive: false, + }); + expect(warning).toContain("Reduce Input or Drive Level if noise or smear appears"); + expect(warning).not.toContain("disable Tight Boost"); + }); + + it("provides compact guidance only for a loaded slimmable Economy amp", () => { + expect(resolveNAMEconomyQualityWarning({ + hasAmpModel: true, + slimmable: true, + requestedQualityValue: 0, + activeQualityValue: 0, + })).toBe("Amp Quality is Economy. Set Amp Quality to Full for the highest-fidelity model graph."); + expect(resolveNAMEconomyQualityWarning({ + hasAmpModel: true, + slimmable: true, + requestedQualityValue: 1, + activeQualityValue: 1, + })).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/namRackPresetTransactions.test.ts b/frontend/src/__tests__/namRackPresetTransactions.test.ts new file mode 100644 index 0000000..d5e64e6 --- /dev/null +++ b/frontend/src/__tests__/namRackPresetTransactions.test.ts @@ -0,0 +1,1097 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync as readRawFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + buildNAMModelQualityOptions, + buildNAMModulePresetCommitValues, + buildNAMRackRollbackPatch, + countNAMUserPresetFilters, + createNAMPresetSessionCache, + deriveNAMRackPresetDirtyState, + drainNAMPresetWriteQueue, + getNAMUserPresetEmptyState, + isNAMReservedPresetCollectionName, + migrateLegacyNAMRackPresetDspState, + migrateNAMRackModelQualityState, + NAM_RACK_DEFAULT_POST_FX_ORDER, + NAM_UNFILED_PRESET_COLLECTION_ID, + NAMPresetSessionInvalidatedError, + normalizeNAMRackSnapshotCaptureTypes, + normalizeNAMUserPresetFolder, + resolveNAMModelQualityOptionValue, + resolveNAMHeaderPresetNavigation, + runNAMHeaderPresetArrowAction, + shouldSynchronizeNAMRackPresetDirtyMarker, + shouldClearNAMPresetIdentityForUnsavedAmpTransition, + verifyNAMRackCompareReadback, +} from "../utils/namRackPresetTransactions"; + +function readFileSync(path: URL, encoding: "utf8"): string { + return readRawFileSync(path, encoding).replace(/\r\n?/g, "\n"); +} + +describe("NAM Rack preset transactions", () => { + it("does not dirty a loaded preset with native capture-type sentinels for empty slots", () => { + expect(normalizeNAMRackSnapshotCaptureTypes({ + pedalModelPath: "", + pedalDeclaredCaptureType: "unknown", + ampModelPath: " C:/NAM/Bass.nam ", + ampDeclaredCaptureType: " full_rig ", + })).toEqual({ + ampDeclaredCaptureType: "full_rig", + }); + + expect(normalizeNAMRackSnapshotCaptureTypes({ + ampModelPath: "", + ampDeclaredCaptureType: "amp_cab", + })).toEqual({}); + }); + + it("drains pending UI persistence before flushing coalesced parameter writes", async () => { + const events: string[] = []; + let releaseUiWrite!: () => void; + const uiWrite = new Promise((resolve) => { + releaseUiWrite = resolve; + }); + const flush = async () => { + events.push("parameters-flushed"); + return true; + }; + + const drain = drainNAMPresetWriteQueue(async () => { + await uiWrite; + events.push("ui-state-drained"); + }, flush); + await Promise.resolve(); + expect(events).toEqual([]); + + releaseUiWrite(); + await expect(drain).resolves.toBe(true); + expect(events).toEqual(["ui-state-drained", "parameters-flushed"]); + }); + + it("aborts a preset transaction when either pending-write stage fails", async () => { + await expect(drainNAMPresetWriteQueue( + async () => undefined, + async () => false, + )).resolves.toBe(false); + await expect(drainNAMPresetWriteQueue( + async () => { throw new Error("UI persistence failed"); }, + async () => true, + )).resolves.toBe(false); + }); + + it("reuses a fresh session-only preset catalog and refreshes after the TTL", async () => { + const cache = createNAMPresetSessionCache(100); + let calls = 0; + const load = () => Promise.resolve([`catalog-${++calls}`]); + + await expect(cache.load(load, { now: 1_000 })).resolves.toEqual(["catalog-1"]); + await expect(cache.load(load, { now: 1_099 })).resolves.toEqual(["catalog-1"]); + await expect(cache.load(load, { now: 1_100 })).resolves.toEqual(["catalog-2"]); + await expect(cache.load(load, { force: true, now: 1_101 })).resolves.toEqual(["catalog-3"]); + expect(calls).toBe(3); + }); + + it("deduplicates current-generation discovery and rejects stale publication after invalidation", async () => { + const cache = createNAMPresetSessionCache(1_000); + const resolvers: Array<(value: string[]) => void> = []; + let calls = 0; + const load = () => new Promise((resolve) => { + calls += 1; + resolvers.push(resolve); + }); + + const first = cache.load(load, { now: 10 }); + const duplicate = cache.load(load, { now: 10 }); + await Promise.resolve(); + expect(calls).toBe(1); + expect(duplicate).toBe(first); + + cache.invalidate(); + const afterMutation = cache.load(load, { force: true, now: 11 }); + await Promise.resolve(); + expect(calls).toBe(2); + + resolvers[0](["stale"]); + resolvers[1](["fresh"]); + await expect(first).rejects.toBeInstanceOf(NAMPresetSessionInvalidatedError); + await expect(afterMutation).resolves.toEqual(["fresh"]); + expect(cache.peek(11)).toEqual(["fresh"]); + }); + + it("restores all prior module-preview values before committing the selected module", () => { + expect(buildNAMModulePresetCommitValues( + { delayMix: 0.42, delayTimeMs: 375 }, + { + applied: true, + previousValues: { + reverbMix: 0.18, + reverbDecaySec: 2.2, + delayMix: 0.1, + }, + }, + )).toEqual({ + reverbMix: 0.18, + reverbDecaySec: 2.2, + delayMix: 0.42, + delayTimeMs: 375, + }); + }); + + it("does not add unrelated values when no temporary module preview exists", () => { + expect(buildNAMModulePresetCommitValues( + { reverbMix: 0.3 }, + { applied: false, previousValues: { delayMix: 0.1 } }, + )).toEqual({ reverbMix: 0.3 }); + }); + + it("navigates within user presets when a user preset is active", () => { + const result = resolveNAMHeaderPresetNavigation({ + factoryPresets: [ + { id: "clean", name: "Clean Template" }, + { id: "lead", name: "Lead Template" }, + ], + userPresets: [ + { name: "Bass Tight" }, + { name: "My Crunch" }, + { name: "Wide Clean" }, + ], + activeFactoryId: "clean", + activeUserPresetName: "my crunch", + }); + + expect(result).toEqual({ + previous: { kind: "user", name: "Bass Tight" }, + next: { kind: "user", name: "Wide Clean" }, + }); + }); + + it("disables truthful user navigation while the active user preset is unavailable", () => { + expect(resolveNAMHeaderPresetNavigation({ + factoryPresets: [ + { id: "clean", name: "Clean Template" }, + { id: "lead", name: "Lead Template" }, + ], + userPresets: [], + activeFactoryId: "clean", + activeUserPresetName: "Saved Session Tone", + })).toEqual({}); + }); + + it("uses each verified user-preset commit for the following navigation step", async () => { + const userPresets = [ + { name: "Alpha" }, + { name: "Bravo" }, + { name: "Charlie" }, + ]; + let activeUserPresetName = "Alpha"; + const loaded: string[] = []; + + const loadNext = async () => { + const target = resolveNAMHeaderPresetNavigation({ + factoryPresets: [], + userPresets, + activeFactoryId: "", + activeUserPresetName, + }).next; + expect(target?.kind).toBe("user"); + if (target?.kind !== "user") return; + await Promise.resolve(); + loaded.push(target.name); + activeUserPresetName = target.name; + }; + + await loadNext(); + await loadNext(); + await loadNext(); + + expect(loaded).toEqual(["Bravo", "Charlie", "Alpha"]); + }); + + it("counts legacy presets even when they have no Recent metadata", () => { + const presets = [ + { name: "high gain!" }, + { name: "Mesa High gain!" }, + ]; + + expect(countNAMUserPresetFilters(presets, {})).toEqual({ + all: 2, + favorites: 0, + recent: 0, + }); + expect(countNAMUserPresetFilters(presets, { + "Mesa High gain!": { favorite: true, lastUsed: 42 }, + })).toEqual({ + all: 2, + favorites: 1, + recent: 1, + }); + }); + + it("distinguishes an empty library from an active filter with no matches", () => { + expect(getNAMUserPresetEmptyState(0, 0, "all", "")).toEqual({ + message: "No saved presets yet", + showAll: false, + }); + expect(getNAMUserPresetEmptyState(2, 0, "recent", "")).toEqual({ + message: "No user presets match the selected filter", + showAll: true, + }); + expect(getNAMUserPresetEmptyState(2, 0, "all", "mesa")).toEqual({ + message: "No user presets match the current search or filter", + showAll: true, + }); + expect(getNAMUserPresetEmptyState(2, 1, "recent", "")).toBeUndefined(); + }); + + it("wraps factory template navigation when no user preset is active", () => { + expect(resolveNAMHeaderPresetNavigation({ + factoryPresets: [ + { id: "clean", name: "Clean Template" }, + { id: "lead", name: "Lead Template" }, + { id: "ambient", name: "Ambient Template" }, + ], + userPresets: [{ name: "User Tone" }], + activeFactoryId: "clean", + })).toEqual({ + previous: { kind: "factory", id: "ambient", name: "Ambient Template" }, + next: { kind: "factory", id: "lead", name: "Lead Template" }, + }); + }); + + it("does not navigate relative to an invisible assumed factory preset", () => { + expect(resolveNAMHeaderPresetNavigation({ + factoryPresets: [ + { id: "clean", name: "Clean Template" }, + { id: "lead", name: "Lead Template" }, + ], + userPresets: [], + activeFactoryId: "", + activeUserPresetName: "", + })).toEqual({}); + }); + + it("enters saved full-rig navigation from the exact empty-rack state", async () => { + const navigation = resolveNAMHeaderPresetNavigation({ + factoryPresets: [ + { id: "clean", name: "Current Capture Clean" }, + { id: "lead", name: "Current Capture Lead" }, + ], + userPresets: [ + { name: "Alpha Saved Rig" }, + { name: "Zulu Saved Rig" }, + ], + activeFactoryId: "", + activeUserPresetName: "", + allowInactiveEntry: true, + }); + + expect(navigation).toEqual({ + previous: { kind: "user", name: "Zulu Saved Rig" }, + next: { kind: "user", name: "Alpha Saved Rig" }, + }); + + let activePresetName = ""; + let libraryOpened = false; + const result = await runNAMHeaderPresetArrowAction(navigation.next, { + loadTarget: async (target) => { + expect(target).toEqual({ kind: "user", name: "Alpha Saved Rig" }); + activePresetName = target.kind === "user" ? target.name : ""; + return true; + }, + openLibrary: () => { libraryOpened = true; }, + }); + + expect(result).toBe("loaded"); + expect(activePresetName).toBe("Alpha Saved Rig"); + expect(libraryOpened).toBe(false); + }); + + it("can enter one saved rig from empty, then disables cycling its one-item collection", async () => { + const options = { + factoryPresets: [{ id: "clean", name: "Current Capture Clean" }], + userPresets: [{ name: "Only Saved Rig" }], + activeFactoryId: "", + allowInactiveEntry: true, + } as const; + + const entryNavigation = resolveNAMHeaderPresetNavigation({ + ...options, + activeUserPresetName: "", + }); + expect(entryNavigation).toEqual({ + previous: { kind: "user", name: "Only Saved Rig" }, + next: { kind: "user", name: "Only Saved Rig" }, + }); + let activeUserPresetName = ""; + await expect(runNAMHeaderPresetArrowAction(entryNavigation.next, { + loadTarget: async (target) => { + activeUserPresetName = target.kind === "user" ? target.name : ""; + return Boolean(activeUserPresetName); + }, + openLibrary: () => undefined, + })).resolves.toBe("loaded"); + expect(activeUserPresetName).toBe("Only Saved Rig"); + expect(resolveNAMHeaderPresetNavigation({ + ...options, + activeUserPresetName, + })).toEqual({}); + }); + + it("preserves the verified identity across empty-rack resource restore, then advances from Alpha to Bravo", async () => { + const userPresets = [ + { name: "Alpha" }, + { name: "Bravo" }, + { name: "Charlie" }, + ]; + expect(shouldClearNAMPresetIdentityForUnsavedAmpTransition({ + previouslyHadAmpModel: false, + hasAmpModel: true, + schemaActiveUserPresetName: "Alpha", + schemaActiveFactoryPresetId: "", + })).toBe(false); + expect(shouldClearNAMPresetIdentityForUnsavedAmpTransition({ + previouslyHadAmpModel: false, + hasAmpModel: true, + schemaActiveUserPresetName: "", + schemaActiveFactoryPresetId: "", + })).toBe(true); + + let activeUserPresetName = "Alpha"; + const next = resolveNAMHeaderPresetNavigation({ + factoryPresets: [], + userPresets, + activeFactoryId: "", + activeUserPresetName, + allowInactiveEntry: true, + }).next; + const result = await runNAMHeaderPresetArrowAction(next, { + loadTarget: async (target) => { + if (target.kind !== "user") return false; + activeUserPresetName = target.name; + return true; + }, + openLibrary: () => undefined, + }); + + expect(result).toBe("loaded"); + expect(activeUserPresetName).toBe("Bravo"); + }); + + it("opens the preset chooser instead of leaving an empty-rack arrow dead", async () => { + const navigation = resolveNAMHeaderPresetNavigation({ + factoryPresets: [ + { id: "clean", name: "Current Capture Clean" }, + { id: "lead", name: "Current Capture Lead" }, + ], + userPresets: [], + activeFactoryId: "", + activeUserPresetName: "", + allowInactiveEntry: true, + }); + + // NAMRackPanel rejects these factory-only targets while there is no Amp + // Capture because they contain settings, not the required NAM resource. + const noLoadableTarget = navigation.next?.kind === "factory" ? undefined : navigation.next; + let libraryOpened = false; + let loadCalls = 0; + const result = await runNAMHeaderPresetArrowAction(noLoadableTarget, { + loadTarget: async () => { + loadCalls += 1; + return true; + }, + openLibrary: () => { libraryOpened = true; }, + }); + + expect(result).toBe("library-opened"); + expect(libraryOpened).toBe(true); + expect(loadCalls).toBe(0); + }); + + it("uses values between NAM slim breakpoints instead of ambiguous threshold values", () => { + expect(buildNAMModelQualityOptions([0.75, 0.25, 0.5, 0.5])).toEqual([ + { value: 0, label: "Economy" }, + { value: 0.375, label: "Balanced 1" }, + { value: 0.625, label: "Balanced 2" }, + { value: 1, label: "Full" }, + ]); + expect(buildNAMModelQualityOptions([0.5])).toEqual([ + { value: 0, label: "Economy" }, + { value: 1, label: "Full" }, + ]); + }); + + it("maps exact NAM slim breakpoints to the higher tier used by the core", () => { + expect(resolveNAMModelQualityOptionValue(0.49, [0.5])).toBe(0); + expect(resolveNAMModelQualityOptionValue(0.5, [0.5])).toBe(1); + expect(resolveNAMModelQualityOptionValue(0.51, [0.5])).toBe(1); + }); + + it("derives edited state from an authoritative preset baseline instead of a stale marker", () => { + expect(deriveNAMRackPresetDirtyState({ + activePresetKind: "user", + hasBaseline: true, + differsFromBaseline: true, + persistedDirty: false, + })).toBe(true); + expect(deriveNAMRackPresetDirtyState({ + activePresetKind: "factory", + hasBaseline: true, + differsFromBaseline: false, + legacyFactoryDiffers: true, + persistedDirty: true, + })).toBe(false); + expect(deriveNAMRackPresetDirtyState({ + activePresetKind: "user", + hasBaseline: false, + differsFromBaseline: true, + persistedDirty: false, + })).toBe(false); + + expect(shouldSynchronizeNAMRackPresetDirtyMarker({ + activePresetKind: "user", + hasBaseline: true, + derivedDirty: true, + persistedDirty: false, + })).toBe(true); + expect(shouldSynchronizeNAMRackPresetDirtyMarker({ + activePresetKind: "user", + hasBaseline: false, + derivedDirty: true, + persistedDirty: false, + })).toBe(false); + }); + + it("migrates unsized legacy captures to Full and retires the legacy global size", () => { + const legacyWithoutQuality = { + values: { ampMix: 1 }, + modelState: { + pedalModelPath: "C:/NAM/Pedal.nam", + ampModelPath: "C:/NAM/Amp.nam", + }, + }; + expect(migrateNAMRackModelQualityState(legacyWithoutQuality)).toEqual({ + values: { ampMix: 1 }, + modelState: { + pedalModelPath: "C:/NAM/Pedal.nam", + pedalModelSize: 1, + ampModelPath: "C:/NAM/Amp.nam", + ampModelSize: 1, + }, + }); + + expect(migrateNAMRackModelQualityState({ + values: { ampMix: 1, namModelSize: 0.37 }, + parameters: [ + { id: "ampMix", value: 1 }, + { id: "namModelSize", value: 0.25 }, + ], + modelState: { + pedalModelPath: "C:/NAM/Pedal.nam", + pedalModelSize: Number.NaN, + ampModelPath: "C:/NAM/Amp.nam", + }, + })).toEqual({ + values: { ampMix: 1 }, + parameters: [{ id: "ampMix", value: 1 }], + modelState: { + pedalModelPath: "C:/NAM/Pedal.nam", + pedalModelSize: 0.37, + ampModelPath: "C:/NAM/Amp.nam", + ampModelSize: 0.37, + }, + }); + + expect(migrateNAMRackModelQualityState({ + values: { ampMix: 1, namModelSize: 0.2 }, + parameters: [{ id: "namModelSize", value: 0.2 }], + })).toEqual({ + values: { ampMix: 1 }, + parameters: [], + }); + }); + + it("canonicalizes a legacy Compare quality selector before strict readback verification", () => { + const migrated = migrateLegacyNAMRackPresetDspState( + migrateNAMRackModelQualityState({ + values: { ampMix: 1, namModelSize: 0.37 }, + modelState: { ampModelPath: "C:/NAM/Amp.nam" }, + }), + { completePreset: true }, + ) as Record; + + expect(migrated.values).not.toHaveProperty("namModelSize"); + expect(migrated.modelState.ampModelSize).toBe(0.37); + expect(verifyNAMRackCompareReadback({ + values: migrated.values, + modelState: migrated.modelState, + dspState: migrated.dspState, + }, { + values: migrated.values, + modelState: { + ampModelPath: "C:/NAM/Amp.nam", + ampModelSize: 0.37, + }, + dspState: migrated.dspState, + })).toBe(true); + }); + + it("migrates complete preset bundles to the one current rack DSP", () => { + const migrated = migrateLegacyNAMRackPresetDspState({ + values: { reverbDecaySec: 10 }, + modelState: { ampModelPath: "C:/NAM/Amp.nam" }, + }, { completePreset: true }) as Record; + expect(migrated).toMatchObject({ + values: { + reverbDecaySec: 10, + precisionDriveVolumeDb: 9, + chaosMode: 0, + chaosWeight: 0.5, + }, + modelState: { ampModelPath: "C:/NAM/Amp.nam" }, + dspState: { reverbEngineVersion: 5, namEffectsDspVersion: 19 }, + }); + + const legacyV9 = { + values: { reverbVoice: 2, reverbDecaySec: 10 }, + dspState: { reverbEngineVersion: 5, namEffectsDspVersion: 9 }, + }; + const currentOnce = migrateLegacyNAMRackPresetDspState(legacyV9, { completePreset: true }); + expect(migrateLegacyNAMRackPresetDspState(currentOnce, { completePreset: true })).toEqual(currentOnce); + + expect(migrateLegacyNAMRackPresetDspState({ + values: {}, + dspState: { reverbEngineVersion: 4 }, + }, { completePreset: true })).toMatchObject({ + values: { precisionDriveVolumeDb: 9, chaosMode: 0, chaosWeight: 0.5 }, + dspState: { reverbEngineVersion: 5, namEffectsDspVersion: 19 }, + }); + }); + + it("reserves built-in collection names and gives empty folders an explicit Unfiled collection", () => { + expect(NAM_UNFILED_PRESET_COLLECTION_ID).toBe("unfiled"); + expect(["all", "Favorites", " recent ", "UNFILED"].every( + (name) => isNAMReservedPresetCollectionName(name), + )).toBe(true); + expect(normalizeNAMUserPresetFolder("Favorites")).toBe(""); + expect(normalizeNAMUserPresetFolder(" Session / Leads ")).toBe("Session Leads"); + }); + + it("builds an exact mutable rollback patch without replaying read-only model flags", () => { + expect(buildNAMRackRollbackPatch({ + values: { + auditionSource: 1, + inputMode: 2, + tapeEchoEnabled: 1, + tapeEchoMix: 0.63, + tapeEchoTimeMs: 480, + tapeEchoFeedback: 0.44, + tapeEchoMod: 0.21, + tapeEchoTone: 0.58, + delayEnabled: 1, + delayMix: 0.27, + delayTimeMs: 640, + delayMode: 1, + pedalMix: 0.37, + ignored: "not-a-number", + }, + modelState: { + pedalModelPath: " C:/NAM/Pedal.nam ", + pedalModelSize: -0.2, + pedalDeclaredCaptureType: "pedal", + ampModelPath: "C:/NAM/Amp.nam", + ampModelSize: 0.62, + ampDeclaredCaptureType: "full_rig", + cabIRPath: "", + hasAmpModel: true, + ampIncludesCab: false, + cabRequestedEnabled: true, + }, + uiState: { + namActivePreview: { slot: "amp" }, + namPresetDirty: true, + namPresetBaseline: { + values: { inputMode: 0, ampMix: 0.8 }, + }, + }, + dspState: { + reverbEngineVersion: 3, + namEffectsDspVersion: 2, + unknownEngineVersion: 99, + }, + })).toEqual({ + values: { + delayEnabled: 1, + delayMix: 0.27, + delayTimeMs: 640, + delayMode: 1, + pedalMix: 0.37, + }, + modelState: { + pedalModelPath: "C:/NAM/Pedal.nam", + pedalModelSize: 0, + pedalDeclaredCaptureType: "pedal", + ampModelPath: "C:/NAM/Amp.nam", + ampModelSize: 0.62, + ampDeclaredCaptureType: "full_rig", + clearCabIR: true, + cabRequestedEnabled: true, + }, + uiState: { + namActivePreview: { slot: "amp" }, + namPresetDirty: true, + namPresetBaseline: { + values: { ampMix: 0.8 }, + }, + }, + dspState: { + reverbEngineVersion: 5, + namEffectsDspVersion: 19, + }, + }); + }); + + it("requires complete Compare readback for every explicitly recalled state domain", () => { + const target = { + values: { ampMix: 0.75, delayEnabled: 0 }, + modelState: { + pedalModelPath: "C:/NAM/Drive.nam", + pedalModelSize: 0.5, + clearAmpModel: true, + cabRequestedEnabled: false, + }, + dspState: { reverbEngineVersion: 5, namEffectsDspVersion: 15 }, + postFxOrder: ["eq", "delay", "mod", "reverb"], + }; + const readback = { + values: { ampMix: 0.75, delayEnabled: 0 }, + modelState: { + pedalModelPath: "C:/NAM/Drive.nam", + pedalModelSize: 0.5, + ampModelPath: "", + cabRequestedEnabled: false, + }, + dspState: { reverbEngineVersion: 5, namEffectsDspVersion: 15 }, + }; + + expect(verifyNAMRackCompareReadback( + target, + readback, + ["eq", "delay", "mod", "reverb"], + )).toBe(true); + expect(verifyNAMRackCompareReadback( + target, + { ...readback, values: { ampMix: 0.75 } }, + target.postFxOrder, + )).toBe(false); + expect(verifyNAMRackCompareReadback( + target, + { ...readback, modelState: { ...readback.modelState, ampModelPath: "C:/NAM/Wrong.nam" } }, + target.postFxOrder, + )).toBe(false); + expect(verifyNAMRackCompareReadback( + target, + { ...readback, dspState: { reverbEngineVersion: 4, namEffectsDspVersion: 2 } }, + target.postFxOrder, + )).toBe(true); + expect(verifyNAMRackCompareReadback(target, readback, ["eq", "mod", "delay", "reverb"])).toBe(false); + + expect(verifyNAMRackCompareReadback( + { ...target, postFxOrder: [...NAM_RACK_DEFAULT_POST_FX_ORDER] }, + readback, + undefined, + )).toBe(true); + expect(verifyNAMRackCompareReadback( + target, + readback, + undefined, + )).toBe(false); + + expect(verifyNAMRackCompareReadback( + { values: { ampMix: 0.75 }, modelState: {} }, + { values: { ampMix: 0.75 }, modelState: {}, dspState: {} }, + )).toBe(true); + }); + + it("does not invent model quality while preparing rollback state", () => { + expect(buildNAMRackRollbackPatch({ + values: {}, + modelState: { + pedalModelPath: "C:/NAM/Pedal.nam", + ampModelPath: "C:/NAM/Amp.nam", + }, + })?.modelState).toEqual({ + pedalModelPath: "C:/NAM/Pedal.nam", + ampModelPath: "C:/NAM/Amp.nam", + clearCabIR: true, + }); + }); + + it("keeps Panel remove/import wiring tied to the transactional helpers", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + const removeStart = panelSource.indexOf("const removeSlotModule"); + const removeEnd = panelSource.indexOf("const applyPreset", removeStart); + const applyStart = removeEnd; + const applyEnd = panelSource.indexOf("const saveUserPreset", applyStart); + const importStart = panelSource.indexOf("const importUserPreset"); + const importEnd = panelSource.indexOf("const rememberIRPath", importStart); + + expect(panelSource.slice(removeStart, removeEnd)).toContain("values.precisionDriveEnabled = 0"); + expect(panelSource.slice(removeStart, removeEnd)).toContain("values.chaosEnabled = 0"); + expect(panelSource.slice(removeStart, removeEnd)).toContain("nextModelState.clearPedalModel = true"); + expect(panelSource).toContain("const triplePreampStackActive = precisionDriveActive && chaosActive && pedalActive"); + expect(panelSource).toContain("their Levels stack"); + expect(panelSource.slice(applyStart, applyEnd)).toContain("reverbEngineVersion: CURRENT_NAM_REVERB_ENGINE_VERSION"); + expect(panelSource.slice(applyStart, applyEnd)).toContain("namEffectsDspVersion: CURRENT_NAM_EFFECTS_DSP_VERSION"); + expect(panelSource).toContain("state: sanitizeNAMRackPortableDspState(state)"); + expect(panelSource.slice(importStart, importEnd)).toContain("buildNAMRackRollbackPatch"); + expect(panelSource.slice(importStart, importEnd)).toContain("rollbackImport"); + expect(panelSource.slice(importStart, importEnd)).toContain("The previous rack was restored."); + }); + + it("keeps the requested external-cab preference in A/B snapshots", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + + expect(panelSource).toContain("modelSnapshot.cabRequestedEnabled = modelState.cabRequestedEnabled"); + expect(panelSource).toContain("currentCabRequest !== savedCabRequest"); + expect(panelSource).toContain("modelState.cabRequestedEnabled = values.cabEnabled >= 0.5"); + }); + + it("keeps NAM model quality as per-slot model state instead of an automatable parameter", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + + expect(panelSource).toContain('modelState: { [sizeKey]: Math.max(0, Math.min(1, requestedSize)) }'); + expect(panelSource).toContain("modelSnapshot.pedalModelSize"); + expect(panelSource).toContain("modelSnapshot.ampModelSize"); + expect(panelSource).toContain("if (Number.isFinite(modelState?.pedalModelSize))"); + expect(panelSource).toContain("if (Number.isFinite(modelState?.ampModelSize))"); + expect(panelSource).not.toContain('paramById(params, "namModelSize")'); + }); + + it("keeps Full quality explicit to new model loads and preserves recall sizes", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + const explorerSource = readFileSync( + new URL("../components/NAMExplorer.tsx", import.meta.url), + "utf8", + ); + const bridgeSource = readFileSync( + new URL("../services/NativeBridge.ts", import.meta.url), + "utf8", + ); + + expect(panelSource).toContain('{ modelSize: NAM_FULL_MODEL_SIZE }'); + expect(explorerSource).toContain("ampModelSize: NAM_FULL_MODEL_SIZE"); + expect(explorerSource).toContain("pedalModelSize: NAM_FULL_MODEL_SIZE"); + expect(explorerSource).toContain("snapshot.ampModelSize === undefined"); + expect(explorerSource).toContain("snapshot.pedalModelSize === undefined"); + expect(bridgeSource).toContain("requestedModelSize"); + expect(bridgeSource).toContain("ampModelSize: requestedModelSize"); + expect(bridgeSource).toContain("pedalModelSize: requestedModelSize"); + }); + + it("stores and synchronizes factory and user dirty state from complete readback baselines", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + const dirtyStart = panelSource.indexOf("const persistedPresetDirty"); + const dirtyEnd = panelSource.indexOf("const currentCompareDirty", dirtyStart); + const dirtySource = panelSource.slice(dirtyStart, dirtyEnd); + const factoryStart = panelSource.indexOf("const applyPreset = async"); + const factoryEnd = panelSource.indexOf("const currentRackToneSlot", factoryStart); + const factorySource = panelSource.slice(factoryStart, factoryEnd); + + expect(dirtySource).toContain("deriveNAMRackPresetDirtyState({"); + expect(dirtySource).toContain("differsFromBaseline: snapshotDiffers(currentSnapshot, activePresetBaseline)"); + expect(dirtySource).not.toContain("snapshotDiffers(currentSnapshot, activePresetBaseline) ||"); + expect(panelSource).toContain("shouldSynchronizeNAMRackPresetDirtyMarker({"); + expect(panelSource).toContain('{ namPresetDirty: isPresetDirty }'); + expect(factorySource).toContain("const loadedBaseline = presetBaselineFromState(loadedState)"); + expect(factorySource).toContain("baseline: loadedBaseline"); + expect(factorySource).toContain("verifyNAMRackCompareReadback(\n loadedBaseline,"); + }); + + it("drains parameter writes and publishes a native readback baseline before showing a saved preset as clean", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + const headerSaveStart = panelSource.indexOf("const saveRackTone"); + const headerSaveEnd = panelSource.indexOf("const loadUserPreset", headerSaveStart); + const headerSave = panelSource.slice(headerSaveStart, headerSaveEnd); + + expect(headerSave).toContain('drainPendingWritesForPresetTransaction("Preset save")'); + expect(headerSave.indexOf('drainPendingWritesForPresetTransaction("Preset save")')).toBeLessThan( + headerSave.indexOf("saveNAMTone({"), + ); + expect(headerSave).toContain("const savedBaseline = await capturePresetBaseline()"); + expect(headerSave).not.toContain("savedBaseline ?? currentSnapshot"); + expect(headerSave.indexOf("await onRefreshRack()")).toBeLessThan( + headerSave.indexOf("setSaveToneOpen(false)"), + ); + + expect(panelSource).not.toContain("const saveUserPreset"); + expect(panelSource).toContain(" { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + const factoryStart = panelSource.indexOf("const applyPreset = async"); + const factoryEnd = panelSource.indexOf("const currentRackToneSlot", factoryStart); + const factoryLoad = panelSource.slice(factoryStart, factoryEnd); + const userStart = panelSource.indexOf("const loadUserPreset = async"); + const userEnd = panelSource.indexOf("const applyHeaderPresetTarget", userStart); + const userLoad = panelSource.slice(userStart, userEnd); + const headerStart = userEnd; + const headerEnd = panelSource.indexOf("const headerPresetTargetLabel", headerStart); + const headerNavigation = panelSource.slice(headerStart, headerEnd); + + expect(panelSource).toContain("type NAMActivePresetIdentity ="); + expect(panelSource).not.toContain("setActiveUserPresetName"); + expect(panelSource).not.toContain("setPresetId"); + expect(panelSource).toContain("schema.uiState?.namActiveFactoryPresetId"); + expect(factoryLoad).toContain("factoryId: nextPreset.id"); + expect(factoryLoad.indexOf("drainPendingWritesForPresetTransaction")).toBeLessThan( + factoryLoad.indexOf("readNAMRackPresetStateWithRetry("), + ); + expect(factoryLoad).toContain("verifiedFactoryId !== nextPreset.id"); + expect(factoryLoad).toContain('publishActivePresetIdentity({ kind: "factory", id: nextPreset.id })'); + expect(factoryLoad.indexOf("await onRefreshRack()")).toBeLessThan( + factoryLoad.indexOf('publishActivePresetIdentity({ kind: "factory", id: nextPreset.id })'), + ); + expect(factoryLoad.indexOf("await onRefreshRack()")).toBeLessThan( + factoryLoad.indexOf("setPresetManagerOpen(false)"), + ); + expect(userLoad).toContain("const verifiedState = identityUpdated"); + expect(userLoad.indexOf("drainPendingWritesForPresetTransaction")).toBeLessThan( + userLoad.indexOf("readNAMRackPresetStateWithRetry("), + ); + expect(userLoad).toContain("const loadedBaseline = presetBaselineFromState(loadedState)"); + expect(userLoad).not.toContain("loadedBaseline ?? currentSnapshot"); + expect(userLoad).toContain('publishActivePresetIdentity({ kind: "user", name: presetName })'); + expect(userLoad.indexOf("await onRefreshRack()")).toBeLessThan( + userLoad.indexOf("setPresetManagerOpen(false)"), + ); + expect(headerNavigation).toContain("presetNavigationPendingRef.current"); + expect(headerNavigation).toContain("activePresetIdentityRef.current"); + expect(headerNavigation).toContain("sameNAMActivePresetIdentity(currentIdentity, targetIdentity)"); + expect(headerNavigation).toContain("runNAMHeaderPresetArrowAction(target"); + expect(headerNavigation).toContain("setPresetManagerOpen(true)"); + expect(panelSource).toContain("allowInactiveEntry: true"); + expect(panelSource).toContain('if (!target || target.kind === "user") return target'); + expect(panelSource).toContain("return targetPreset?.requiresAmpModel ? undefined : target"); + expect(panelSource).toContain('onPreviousPreset={!presetBusy && !presetManagerBusy && headerPreviousPresetAvailable\n ? () => void activateHeaderPresetDirection("previous")'); + expect(panelSource).toContain('onNextPreset={!presetBusy && !presetManagerBusy && headerNextPresetAvailable\n ? () => void activateHeaderPresetDirection("next")'); + expect(panelSource).toContain("headerPresetNavigation.previous || !activePresetIdentity"); + expect(panelSource).toContain("headerPresetNavigation.next || !activePresetIdentity"); + expect(panelSource).toContain("shouldClearNAMPresetIdentityForUnsavedAmpTransition({"); + expect(panelSource).toContain("schemaActiveUserPresetName,"); + expect(panelSource).toContain("schemaActiveFactoryPresetId,"); + expect(panelSource).toContain('setPresetStatus(`No other ${activePresetIdentityRef.current.kind === "user" ? "user preset" : "template"} is available`)'); + expect(panelSource).toContain('`${direction} unavailable: no other ${activePresetIdentity.kind === "user" ? "user preset" : "template"} available`'); + expect(panelSource).toContain('`${direction} preset: open Preset Library`'); + expect(panelSource).toContain("const reapplyActivePreset = async"); + expect(panelSource).toContain("onClick={() => void reapplyActivePreset()}"); + expect(panelSource).toContain('User: {activeUserPresetName}'); + expect(panelSource).toContain("presetStatus && !presetManagerOpen"); + expect(panelSource).toContain('data-qa="nam-preset-previous"'); + expect(panelSource).toContain('data-qa="nam-preset-next"'); + const managerSource = readFileSync( + new URL("../components/NAMPresetManagerModal.tsx", import.meta.url), + "utf8", + ); + expect(managerSource).toContain('aria-current={entry.active ? "true" : undefined}'); + expect(managerSource).toContain('data-qa="nam-preset-load-selected"'); + }); + + it("rolls back a changed rack when post-load verification fails", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + const factoryStart = panelSource.indexOf("const applyPreset = async"); + const factoryEnd = panelSource.indexOf("const currentRackToneSlot", factoryStart); + const factoryLoad = panelSource.slice(factoryStart, factoryEnd); + const userStart = panelSource.indexOf("const loadUserPreset = async"); + const userEnd = panelSource.indexOf("const applyHeaderPresetTarget", userStart); + const userLoad = panelSource.slice(userStart, userEnd); + + for (const loadSource of [factoryLoad, userLoad]) { + expect(loadSource).toContain("const rackStateBeforeLoad = await readNAMRackPresetStateWithRetry("); + expect(loadSource).not.toContain("const rackStateBeforeLoad = await nativeBridge.getBuiltInPluginState(address)"); + expect(loadSource).toContain("rollbackPatch = buildNAMRackRollbackPatch(rackStateBeforeLoad)"); + expect(loadSource).toContain("await recoverUnverifiedPresetMutation("); + expect(loadSource.indexOf("buildNAMRackRollbackPatch(rackStateBeforeLoad)")).toBeLessThan( + loadSource.indexOf("rackMutated = true"), + ); + } + expect(panelSource).toContain("The previous rack was restored."); + expect(panelSource).toContain("publishActivePresetIdentity(null)"); + expect(panelSource).toContain("No preset is marked active."); + }); + + it("keeps preset discovery in a TTL session cache with explicit and mutation refreshes", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + const managerSource = readFileSync( + new URL("../components/NAMPresetManagerModal.tsx", import.meta.url), + "utf8", + ); + + expect(panelSource).toContain("createNAMPresetSessionCache"); + expect(panelSource).toContain("namUserPresetLibrarySession.peek() ?? []"); + expect(panelSource).toContain("namUserPresetLibrarySession.load(async () =>"); + expect(panelSource).toContain("namUserPresetLibrarySession.invalidate()"); + expect(panelSource).toContain("refreshUserPresetsAfterMutation()"); + expect(panelSource).toContain('const managerFactoryPresets = presetFolderFilter === "all"'); + expect(panelSource).toContain("presetFilterCounts.all + profileFactoryPresets.length"); + expect(managerSource).toContain('data-qa="nam-preset-refresh"'); + expect(managerSource).toContain("onRefresh: () => void | Promise"); + }); + + it("uses native button semantics and locks the preset manager during transactions", () => { + const managerSource = readFileSync( + new URL("../components/NAMPresetManagerModal.tsx", import.meta.url), + "utf8", + ); + const modalSource = readFileSync( + new URL("../components/ui/Modal/Modal.tsx", import.meta.url), + "utf8", + ); + const managerCss = readFileSync( + new URL("../components/NAMPresetManagerModal.css", import.meta.url), + "utf8", + ); + expect(managerSource).not.toContain('role="listbox"'); + expect(managerSource).not.toContain('role="option"'); + expect(managerSource).toContain("aria-pressed={selected}"); + expect(managerSource).toContain("aria-disabled={entry.disabled || undefined}"); + expect(managerSource).toContain("actionPendingRef.current"); + expect(managerSource).toContain("const locked = busy || actionPending"); + expect(managerSource).toContain("closeOnEscape={!locked}"); + expect(managerSource).toContain("closeOnOverlayClick={!locked}"); + expect(managerSource).toContain('id="nam-preset-manager-dialog"'); + expect(managerSource).toContain("autoFocus"); + expect(managerSource).toContain("aria-busy={locked || undefined}"); + expect(managerSource).toContain('type="search"'); + expect(managerSource).toContain("disabled={locked}"); + expect(managerSource).toContain(" { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + const recallStart = panelSource.indexOf("const recallCompareSlot"); + const recallEnd = panelSource.indexOf("const rememberValues", recallStart); + const recallSource = panelSource.slice(recallStart, recallEnd); + + expect(recallSource).toContain("presetTransactionPendingRef.current = true"); + expect(recallSource).toContain("drainPendingWritesForPresetTransaction"); + expect(recallSource).toContain("getBuiltInPluginState(address)"); + expect(recallSource).toContain("authoritativeCurrentSnapshot = presetBaselineFromState(authoritativeState)"); + expect(recallSource).toContain("rollbackCoversValues"); + expect(recallSource).toContain("rollbackCoversDsp"); + expect(recallSource).toContain("mutationAttempted = true"); + expect(recallSource).toContain("if (!ok) throw new Error(failureReason)"); + expect(recallSource).toContain("...(target.dspState ? { dspState: target.dspState } : {})"); + expect(recallSource).toContain("verifyNAMRackCompareReadback(target, verifiedState, verifiedPostFxOrder)"); + expect(recallSource).toContain("sameNAMPresetIdentityStatus"); + expect(recallSource).toContain("setBuiltInPluginState(address, rollbackPatch)"); + expect(recallSource).toContain("The previous rack was restored"); + expect(recallSource).not.toContain("current Preset was retained"); + expect(recallSource).toContain("presetTransactionPendingRef.current = false"); + expect(panelSource).toContain("dspState: stateRecord.dspState"); + expect(panelSource).toContain("disabled={presetBusy || presetManagerBusy}"); + }); + + it("reports direct installed-model mutation and refresh failures without rejected event promises", () => { + const explorerSource = readFileSync( + new URL("../components/NAMExplorer.tsx", import.meta.url), + "utf8", + ); + for (const [startMarker, endMarker] of [ + ["const reinstallInstalled", "const updateInstalled"], + ["const updateInstalled", "const toggleInstalledFavorite"], + ["const toggleInstalledFavorite", "const removeInstalled"], + ["const removeInstalled", "const snapshotValues"], + ] as const) { + const start = explorerSource.indexOf(startMarker); + const end = explorerSource.indexOf(endMarker, start); + const actionSource = explorerSource.slice(start, end); + expect(actionSource).toContain("try {"); + expect(actionSource).toContain("beginInstalledLibraryMutation("); + expect(actionSource).toContain("mutationCompleted = true"); + expect(actionSource).toContain("await refreshInstalledLibraryAfterMutation()"); + expect(actionSource).toContain("catch (error)"); + expect(actionSource).toContain("Retry Refresh"); + expect(actionSource).toContain("finally {"); + expect(actionSource).toContain("finishInstalledLibraryMutation(owner, key"); + } + + const designPortSource = readFileSync( + new URL("../components/NAMRackDesignPort.tsx", import.meta.url), + "utf8", + ); + expect(explorerSource).toContain("const installedLibraryMutationOwnerRef = useRef(null)"); + expect(explorerSource).toContain("isNAMRackTransactionBusy(rackTransactionKey)"); + expect(explorerSource).toContain("installedLibraryMutationOwnerRef.current !== owner"); + expect(explorerSource).toContain("rackTransactionBusy || installedLibraryMutationPending || saveToneBusy"); + expect(explorerSource).toContain("disabled={rackActionsBusy || busyLibraryKey === installedKey(removeCandidate)}"); + expect(designPortSource).toContain("disabled={config.busy}"); + }); + + it("renders and filters Unfiled separately from reserved built-in collections", () => { + const panelSource = readFileSync( + new URL("../components/NAMRackPanel.tsx", import.meta.url), + "utf8", + ); + + expect(panelSource).toContain("presetFolderFilter === NAM_UNFILED_PRESET_COLLECTION_ID && !folder"); + expect(panelSource).toContain('label: "Unfiled"'); + expect(panelSource).toContain("isNAMReservedPresetCollectionName(result)"); + expect(panelSource).toContain("is a built-in collection name"); + }); + + it("restores an active module preview before previewing or applying another module preset", () => { + const explorerSource = readFileSync( + new URL("../components/NAMExplorer.tsx", import.meta.url), + "utf8", + ); + const applyStart = explorerSource.indexOf("const applyOpenStudioFXPreset"); + const applyEnd = explorerSource.indexOf("const revertOpenStudioFXPreset", applyStart); + const applySource = explorerSource.slice(applyStart, applyEnd); + + expect(applySource).toContain( + "values: buildNAMModulePresetCommitValues(presetPatch.values, fxPreview)", + ); + expect(applySource).toContain(": publishedPresetPatch"); + }); +}); diff --git a/frontend/src/__tests__/namRackStyleOwnership.test.ts b/frontend/src/__tests__/namRackStyleOwnership.test.ts new file mode 100644 index 0000000..fb910e2 --- /dev/null +++ b/frontend/src/__tests__/namRackStyleOwnership.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +const componentTypeScriptSources = import.meta.glob("../components/*.tsx", { + eager: true, + import: "default", + query: "?raw", +}) as Record; + +const componentSources = componentTypeScriptSources; + +const entrySources = import.meta.glob("../PluginEditorWindowApp.tsx", { + eager: true, + import: "default", + query: "?raw", +}) as Record; + +const componentStyles = import.meta.glob("../components/*.css", { + eager: true, + import: "default", + query: "?raw", +}) as Record; + +const componentSource = (fileName: string) => + componentSources[`../components/${fileName}`] ?? ""; + +const componentStyle = (fileName: string) => + componentStyles[`../components/${fileName}`] ?? ""; + +const componentOwners = [ + "NAMRackChainModule", + "NAMRackControlTooltip", + "NAMRackDiagnostics", + "NAMRackKnob", + "NAMRackMixer", +] as const; + +const featureStyles = [ + "NAMRackBrowser", + "NAMRackPresets", + "NAMRackPedalboard", + "NAMRackSourceFlow", + "NAMRackNeural", + "NAMRackCalibration", +] as const; + +describe("NAM Rack stylesheet ownership", () => { + it("keeps the parent stylesheet owned by NAMRackPanel instead of global entry points", () => { + expect(componentSource("NAMRackPanel.tsx")).toContain( + 'import "./NAMRackPanel.css";', + ); + expect(entrySources["../PluginEditorWindowApp.tsx"] ?? "").not.toContain( + "NAMRackPanel.css", + ); + expect(componentSource("FXChainPanel.tsx")).not.toContain( + "NAMRackPanel.css", + ); + }); + + it.each(componentOwners)("%s imports its own non-empty stylesheet", (owner) => { + expect(componentSource(`${owner}.tsx`)).toContain( + `import "./${owner}.css";`, + ); + }); + + it.each(featureStyles)( + "%s remains an explicit parent-owned feature stylesheet", + (feature) => { + expect(componentSource("NAMRackPanel.tsx")).toContain( + `import "./${feature}.css";`, + ); + }, + ); + + it("keeps Design Port style ownership explicit", () => { + const source = componentSource("NAMRackDesignPort.tsx"); + for (const stylesheet of [ + "NAMRackDesignPort.css", + "NAMRackStage.css", + "NAMRackHardware.css", + "NAMRackDesignPortSourceFlow.css", + "NAMRackFooter.css", + "NAMRackHeader.css", + ]) { + expect(source).toContain(`import "./${stylesheet}";`); + } + }); + + it("does not reserve a scrollbar gutter around the detached NAM editor", () => { + expect(componentStyle("FXChainPanel.css")).not.toMatch( + /\.plugin-editor-window-app \.builtin-plugin-panel\[data-kind="nam"\]\s*\{[^}]*scrollbar-gutter:\s*stable/, + ); + expect(componentStyle("NAMRackStage.css")).not.toMatch( + /\.nam-rack-design-port\s*\{[^}]*scrollbar-gutter:\s*stable/, + ); + }); +}); diff --git a/frontend/src/__tests__/namRackTelemetryCadence.test.ts b/frontend/src/__tests__/namRackTelemetryCadence.test.ts new file mode 100644 index 0000000..3239cdb --- /dev/null +++ b/frontend/src/__tests__/namRackTelemetryCadence.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { + namRackTelemetryIntervalMs, + shouldRefreshNAMRackDiagnostics, +} from "../utils/namRackTelemetryCadence"; + +describe("NAM Rack telemetry cadence", () => { + it("publishes one 5 Hz combined update while the tuner is closed", () => { + expect(namRackTelemetryIntervalMs(false)).toBe(200); + expect([0, 1, 2, 3].map((tick) => shouldRefreshNAMRackDiagnostics(false, tick))) + .toEqual([true, true, true, true]); + }); + + it("keeps tuner pitch at 10 Hz without transferring full diagnostics above 5 Hz", () => { + expect(namRackTelemetryIntervalMs(true)).toBe(100); + expect([0, 1, 2, 3, 4, 5].map((tick) => shouldRefreshNAMRackDiagnostics(true, tick))) + .toEqual([true, false, true, false, true, false]); + }); +}); diff --git a/frontend/src/__tests__/namReverbPadControls.test.ts b/frontend/src/__tests__/namReverbPadControls.test.ts new file mode 100644 index 0000000..68d2600 --- /dev/null +++ b/frontend/src/__tests__/namReverbPadControls.test.ts @@ -0,0 +1,135 @@ +// @ts-expect-error Vitest provides Node builtins while the app tsconfig omits Node typings. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { createNAMBootSchema } from "../components/BuiltInPluginPanel"; +import { NAM_POST_FX_FACEPLATE_LAYOUT } from "../components/NAMRackDesignPort"; +import { NAM_RACK_REVERB_ADVANCED_CONTROL_GROUPS } from "../components/NAMRackMixer"; +import { + isCurrentNAMRackPresetState, + migrateLegacyNAMRackPresetDspState, +} from "../utils/namRackPresetTransactions"; + +describe("NAM Rack dedicated Reverb PAD frontend", () => { + it("publishes one automatable toggle with a deterministic off default", () => { + const schema = createNAMBootSchema( + { chain: "track", trackId: "pad-test", fxIndex: 0 }, + "OpenStudio NAM Rack", + ); + + expect(schema.parameters.find(({ id }) => id === "reverbPad")).toMatchObject({ + label: "Pad", + type: "toggle", + value: 0, + min: 0, + max: 1, + defaultValue: 0, + automatable: true, + graphRole: "space", + }); + expect(NAM_RACK_REVERB_ADVANCED_CONTROL_GROUPS[0].paramIds).toContain("reverbPad"); + }); + + it("migrates missing PAD state off, preserves explicit on, and sanitizes toggle values", () => { + const migrate = (values: Record) => migrateLegacyNAMRackPresetDspState({ + values, + dspState: { namEffectsDspVersion: 12, reverbEngineVersion: 5 }, + }, { completePreset: true }) as { values: Record }; + + const missing = migrate({ reverbShimmer: 0.7 }); + const on = migrate({ reverbPad: 1, reverbShimmer: 0.7 }); + const clampedOn = migrate({ reverbPad: 20 }); + const nonFinite = migrate({ reverbPad: Number.NaN }); + + expect(missing.values.reverbPad).toBe(0); + expect(on.values).toMatchObject({ reverbPad: 1, reverbShimmer: 0.7 }); + expect(clampedOn.values.reverbPad).toBe(1); + expect(nonFinite.values.reverbPad).toBe(0); + expect(isCurrentNAMRackPresetState(on)).toBe(true); + }); + + it("canonicalizes PAD inside saved baseline and A/B snapshots", () => { + const migrated = migrateLegacyNAMRackPresetDspState({ + values: { reverbPad: 1 }, + dspState: { namEffectsDspVersion: 12, reverbEngineVersion: 5 }, + uiState: { + namPresetBaseline: { + values: { reverbShimmer: 0.25 }, + dspState: { namEffectsDspVersion: 12, reverbEngineVersion: 5 }, + }, + namRackCompare: { + snapshots: { + A: { + values: { reverbPad: 1 }, + dspState: { namEffectsDspVersion: 12, reverbEngineVersion: 5 }, + }, + B: { + values: { reverbPad: -20 }, + dspState: { namEffectsDspVersion: 12, reverbEngineVersion: 5 }, + }, + }, + }, + }, + }, { completePreset: true }) as { + uiState: { + namPresetBaseline: { values: Record }; + namRackCompare: { snapshots: Record<"A" | "B", { values: Record }> }; + }; + }; + + expect(migrated.uiState.namPresetBaseline.values.reverbPad).toBe(0); + expect(migrated.uiState.namRackCompare.snapshots.A.values.reverbPad).toBe(1); + expect(migrated.uiState.namRackCompare.snapshots.B.values.reverbPad).toBe(0); + }); + + it("fits a compact PAD toggle beside the one Engage footswitch without resizing the asset", () => { + const { box } = NAM_POST_FX_FACEPLATE_LAYOUT.modules.reverb; + const footer = NAM_POST_FX_FACEPLATE_LAYOUT.reverb; + + expect(box).toEqual({ x: 528, y: 29, w: 220, h: 195 }); + expect(footer.secondaryX).toBe(34); + expect(footer.primaryX).toBe(66); + expect(footer.padToggleSize * box.w / 100).toBe(NAM_POST_FX_FACEPLATE_LAYOUT.reverb.voiceSelector.size * box.w / 100); + expect(footer.padToggleSize).toBeLessThan(footer.footSize); + expect(footer.secondaryLedSize).toBe(footer.ledSize); + + const design = readFileSync(new URL("../components/NAMRackDesignPort.tsx", import.meta.url), "utf8"); + const reverbStart = design.indexOf('name="reverb"'); + const reverbEnd = design.indexOf("", reverbStart); + const reverb = design.slice(reverbStart, reverbEnd); + expect(reverb).toContain(" { + const design = readFileSync(new URL("../components/NAMRackDesignPort.tsx", import.meta.url), "utf8"); + const reverbStart = design.indexOf('name="reverb"'); + const reverbEnd = design.indexOf("", reverbStart); + const reverb = design.slice(reverbStart, reverbEnd); + + expect(reverb).toContain('paramId="reverbShimmer"'); + expect(reverb).toContain("labelText={reverbLabels.texture}"); + expect(reverb).toContain("value={`${reverbLabels.texture}: 0%`}"); + expect(reverb).not.toContain('labelText={reverbPadActive ? "PAD"'); + expect(reverb).not.toContain("Pad intensity"); + }); + + it("keeps module-copy/reset and factory preset surfaces synchronized", () => { + const panel = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + + const moduleMapStart = panel.indexOf("function moduleParamIds"); + const moduleMapEnd = panel.indexOf("function normalizeRackModuleCopy", moduleMapStart); + expect(panel.slice(moduleMapStart, moduleMapEnd)).toContain('"reverbEnabled", "reverbPad"'); + + const presetsStart = panel.indexOf("const NAM_RACK_PRESETS"); + const presetsEnd = panel.indexOf("function clamp(value", presetsStart); + expect(panel.slice(presetsStart, presetsEnd).match(/reverbPad: 0/g)).toHaveLength(8); + + const explorer = readFileSync(new URL("../components/NAMExplorer.tsx", import.meta.url), "utf8"); + const plateStart = explorer.indexOf('id: "plate-room"'); + const plateEnd = explorer.indexOf("\n },", plateStart); + expect(explorer.slice(plateStart, plateEnd)).toContain("reverbPad: 0"); + }); +}); diff --git a/frontend/src/__tests__/namReverbV5Controls.test.ts b/frontend/src/__tests__/namReverbV5Controls.test.ts new file mode 100644 index 0000000..d1d91a7 --- /dev/null +++ b/frontend/src/__tests__/namReverbV5Controls.test.ts @@ -0,0 +1,146 @@ +// @ts-expect-error Vitest provides Node builtins while the app tsconfig omits Node typings. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { createNAMBootSchema } from "../components/BuiltInPluginPanel"; +import { + NAM_PEDAL_HARDWARE_STANDARD_PX, + NAM_POST_FX_FACEPLATE_LAYOUT, + NAM_REVERB_VOICE_LABELS, + NAM_REVERB_VOICE_CONTROL_LABELS, + NAM_REVERB_VOICE_SELECTOR_PX, + NAM_REVERB_VOICE_SELECTOR_ROTATIONS, + namReverbVoiceSelectorDetentPlacement, + reverbVoiceDisplayLabel, + reverbVoiceControlLabels, +} from "../components/NAMRackDesignPort"; +import { + CURRENT_NAM_EFFECTS_DSP_VERSION, + CURRENT_NAM_REVERB_ENGINE_VERSION, + isCurrentNAMRackPresetState, + migrateLegacyNAMRackPresetDspState, + normalizeNAMReverbVoice, +} from "../utils/namRackPresetTransactions"; + +describe("NAM Rack Reverb V5 voice contract", () => { + it("migrates every pre-V9 complete preset to the exact Studio compatibility voice", () => { + const migrated = migrateLegacyNAMRackPresetDspState({ + values: { reverbVoice: 3, reverbDecaySec: 3.4 }, + dspState: { namEffectsDspVersion: 8, reverbEngineVersion: 4 }, + }, { completePreset: true }) as { values: Record; dspState: Record }; + + expect(CURRENT_NAM_EFFECTS_DSP_VERSION).toBe(19); + expect(CURRENT_NAM_REVERB_ENGINE_VERSION).toBe(5); + expect(migrated.values.reverbVoice).toBe(0); + expect(migrated.values.reverbDecaySec).toBe(3.4); + expect(migrated.dspState).toEqual({ namEffectsDspVersion: 19, reverbEngineVersion: 5 }); + expect(isCurrentNAMRackPresetState(migrated)).toBe(true); + }); + + it("clamps the one current four-state voice enum", () => { + expect([0, 1, 2, 3].map(normalizeNAMReverbVoice)).toEqual([0, 1, 2, 3]); + expect(normalizeNAMReverbVoice(-2)).toBe(0); + expect(normalizeNAMReverbVoice(99)).toBe(3); + expect(normalizeNAMReverbVoice(Number.NaN)).toBe(0); + expect(NAM_REVERB_VOICE_LABELS).toEqual(["STUDIO", "PLATE", "HALL", "ROOM"]); + expect([0, 1, 2, 3].map((value) => reverbVoiceDisplayLabel(value))) + .toEqual(["STUDIO", "PLATE", "HALL", "ROOM"]); + }); + + it("uses a toggle-sized selector, exactly four aligned detents, and one screen label", () => { + const { voiceDisplay, voiceSelector, topRowY, topKnobSize } = NAM_POST_FX_FACEPLATE_LAYOUT.reverb; + const box = NAM_POST_FX_FACEPLATE_LAYOUT.modules.reverb.box; + const selectorRadius = voiceSelector.size * box.w / 200; + const selectorBottom = voiceSelector.y * box.h / 100 + selectorRadius; + const displayBottom = (voiceDisplay.y + voiceDisplay.h) * box.h / 100; + const topKnobTop = topRowY * box.h / 100 - topKnobSize * box.w / 200; + + expect(NAM_REVERB_VOICE_SELECTOR_PX).toBe(NAM_PEDAL_HARDWARE_STANDARD_PX.toggle); + expect(voiceSelector.size * box.w / 100).toBeCloseTo(24, 8); + expect(selectorBottom).toBeCloseTo(displayBottom, 8); + expect(topKnobTop - displayBottom).toBeGreaterThanOrEqual(4); + expect(NAM_REVERB_VOICE_SELECTOR_ROTATIONS).toEqual([-60, -20, 20, 60]); + expect(NAM_REVERB_VOICE_SELECTOR_ROTATIONS.map(namReverbVoiceSelectorDetentPlacement)).toHaveLength(4); + }); + + it("wires the voice across schema, state, advanced controls, and the live pedal", () => { + const sources = { + bridge: readFileSync(new URL("../services/NativeBridge.ts", import.meta.url), "utf8"), + panel: readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"), + mixer: readFileSync(new URL("../components/NAMRackMixer.tsx", import.meta.url), "utf8"), + design: readFileSync(new URL("../components/NAMRackDesignPort.tsx", import.meta.url), "utf8"), + }; + for (const source of Object.values(sources)) expect(source).toContain("reverbVoice"); + expect(sources.design).toContain(" id === "reverbVoice")).toMatchObject({ + label: "Reverb Voice", + type: "enum", + value: 0, + min: 0, + max: 3, + defaultValue: 0, + enumOptions: [ + { value: 0, label: "Studio" }, + { value: 1, label: "Plate" }, + { value: 2, label: "Hall" }, + { value: 3, label: "Room" }, + ], + }); + }); + + it("keeps complete factory starting points deterministic for the new voice engine", () => { + const panel = readFileSync(new URL("../components/NAMRackPanel.tsx", import.meta.url), "utf8"); + const defaultsStart = panel.indexOf("const NAM_RACK_GLOBAL_DEFAULT_VALUES"); + const defaultsEnd = panel.indexOf("type RackCompareSnapshot", defaultsStart); + const defaults = panel.slice(defaultsStart, defaultsEnd); + for (const line of [ + "reverbVoice: 0", + "reverbMix: 0.28", + "reverbDecaySec: 2.2", + "reverbTone: 0.62", + "reverbPreDelayMs: 18", + "reverbLowCutHz: 120", + "reverbShimmer: 0", + "reverbPad: 0", + "reverbEnabled: 0", + ]) expect(defaults).toContain(line); + + const explorer = readFileSync(new URL("../components/NAMExplorer.tsx", import.meta.url), "utf8"); + const plateStart = explorer.indexOf('id: "plate-room"'); + const plateEnd = explorer.indexOf("\n },", plateStart); + const platePreset = explorer.slice(plateStart, plateEnd); + expect(platePreset).toContain("reverbVoice: 1"); + expect(platePreset).toContain("reverbLowCutHz: 120"); + expect(platePreset).toContain("reverbShimmer: 0"); + expect(platePreset).toContain("reverbPad: 0"); + }); + + it("keeps the shared macros stable and gives every voice truthful texture labels", () => { + expect(NAM_REVERB_VOICE_CONTROL_LABELS).toEqual([ + { preDelay: "PRE DLY", decay: "DECAY", mix: "MIX", lowCut: "LOW CUT", tone: "TONE", texture: "AIR" }, + { preDelay: "PRE DLY", decay: "DECAY", mix: "MIX", lowCut: "LOW CUT", tone: "DAMP", texture: "SHIMMER" }, + { preDelay: "PRE DLY", decay: "DECAY", mix: "MIX", lowCut: "LOW CUT", tone: "DAMP", texture: "MOTION" }, + { preDelay: "PRE DLY", decay: "SIZE", mix: "MIX", lowCut: "LOW CUT", tone: "TONE", texture: "EARLY" }, + ]); + expect([0, 1, 2, 3].map((value) => reverbVoiceControlLabels(value))) + .toEqual(NAM_REVERB_VOICE_CONTROL_LABELS); + const design = readFileSync(new URL("../components/NAMRackDesignPort.tsx", import.meta.url), "utf8"); + expect(design).toContain("labelText={reverbLabels.decay}"); + expect(design).toContain("labelText={reverbLabels.texture}"); + expect(design).toContain("value={`${reverbLabels.texture}: 0%`}"); + expect(design).not.toContain('reverbPadActive ? "PAD" : reverbLabels.texture'); + expect(design).not.toContain("Pad Intensity"); + expect(design).toContain("semanticLabel={reverbLabels.decay === \"SIZE\" ? \"Room Size\" : \"Decay\"}"); + expect(design).not.toContain("labelText=\"VOICE\""); + }); +}); diff --git a/frontend/src/__tests__/namToneCapturePickerAccessibility.test.ts b/frontend/src/__tests__/namToneCapturePickerAccessibility.test.ts new file mode 100644 index 0000000..13d6b2b --- /dev/null +++ b/frontend/src/__tests__/namToneCapturePickerAccessibility.test.ts @@ -0,0 +1,33 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const pickerSource = readFileSync( + new URL("../components/NAMToneCapturePicker.tsx", import.meta.url), + "utf8", +).replace(/\r\n?/g, "\n"); +const pickerCSS = readFileSync( + new URL("../components/NAMToneCapturePicker.css", import.meta.url), + "utf8", +).replace(/\r\n?/g, "\n"); + +describe("NAM tone capture picker accessibility", () => { + it("uses a labelled group of native buttons rather than composite listbox roles", () => { + expect(pickerSource).toContain('role="group"'); + expect(pickerSource).toContain("aria-label={`Available NAM captures in ${title}`}"); + expect(pickerSource).toContain("aria-pressed={selected}"); + expect(pickerSource).not.toContain('role="listbox"'); + expect(pickerSource).not.toContain('role="option"'); + expect(pickerSource).not.toContain("aria-selected={selected}"); + }); + + it("keeps picker copy legible and supplies practical keyboard/action targets", () => { + expect(pickerCSS).toMatch(/\.nam-tone-capture-select\s*\{[^}]*min-height:\s*42px;/s); + expect(pickerCSS).toMatch(/\.nam-tone-capture-name\s*\{[^}]*font-size:\s*12px;/s); + expect(pickerCSS).toMatch(/\.nam-tone-capture-badges i\s*\{[^}]*font-size:\s*10px;/s); + expect(pickerCSS).toMatch(/\.nam-tone-capture-actions button\s*\{[^}]*min-height:\s*36px;/s); + expect(pickerCSS).toMatch(/\.nam-tone-capture-actions button\s*\{[^}]*font-size:\s*11px;/s); + expect(pickerCSS).toContain(".nam-tone-capture-select:focus-visible"); + expect(pickerCSS).toContain("outline: 2px solid #f5ae27"); + }); +}); diff --git a/frontend/src/__tests__/namToneCaptureSelection.test.ts b/frontend/src/__tests__/namToneCaptureSelection.test.ts new file mode 100644 index 0000000..185ba4b --- /dev/null +++ b/frontend/src/__tests__/namToneCaptureSelection.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import type { NAMCatalogTone } from "../services/NativeBridge"; +import { captureTypeForToneModel } from "../utils/namCaptureType"; +import { + collapseNAMCatalogRowsToTonePacks, + isExplicitNAMCatalogCaptureSelection, + namCatalogCaptureSelectionKey, + namToneCaptureOptions, + namToneDeclaredCaptureCount, + namToneRequiresExplicitCapture, + sameNAMCatalogModelIdentity, + selectedNAMCatalogIdentity, +} from "../utils/namToneCaptureSelection"; + +const pack: NAMCatalogTone = { + id: 67139, + title: "Headbangers Ball Amp Pack IR/RAW", + gear: "amp", + a2_models_count: 3, + models: [ + { + id: 6713901, + tone_id: 67139, + name: "HB 01 RAW", + architecture_version: 2, + model_url: "https://example.invalid/hb-01-raw.nam", + }, + { + id: 6713902, + tone_id: 67139, + name: "HB 01 IR", + architecture_version: 2, + model_url: "https://example.invalid/hb-01-ir.nam", + }, + { + id: 6713902, + tone_id: 67139, + name: "Duplicate identity", + architecture_version: 2, + model_url: "https://example.invalid/duplicate.nam", + }, + ], +}; + +describe("NAM tone pack capture selection", () => { + it("deduplicates child captures while preserving exact durable identity", () => { + const options = namToneCaptureOptions(pack); + expect(options.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: "67139:6713901", name: "HB 01 RAW" }, + { id: "67139:6713902", name: "HB 01 IR" }, + ]); + expect(options.map(({ architecture }) => architecture)).toEqual(["A2", "A2"]); + }); + + it("requires explicit selection for a multi-capture pack", () => { + expect(namToneDeclaredCaptureCount(pack)).toBe(2); + expect(namToneRequiresExplicitCapture(pack)).toBe(true); + expect(namToneRequiresExplicitCapture({ ...pack, models: [pack.models![0]], a2_models_count: 1 })).toBe(false); + }); + + it("gives URL-only captures distinct durable selection identities", () => { + const urlOnlyPack: NAMCatalogTone = { + id: 88001, + title: "URL-only pack", + models: [ + { name: "Clean", model_url: "https://example.invalid/captures/clean.nam" }, + { name: "Lead", model_url: "https://example.invalid/captures/lead.nam" }, + ], + }; + const options = namToneCaptureOptions(urlOnlyPack); + + expect(options.map(({ id }) => id)).toEqual([ + "88001:url:https%3A%2F%2Fexample.invalid%2Fcaptures%2Fclean.nam", + "88001:url:https%3A%2F%2Fexample.invalid%2Fcaptures%2Flead.nam", + ]); + expect(new Set(options.map(({ id }) => id)).size).toBe(2); + expect(sameNAMCatalogModelIdentity(options[1].model, { + modelUrl: "HTTPS://EXAMPLE.INVALID/CAPTURES/LEAD.NAM", + })).toBe(true); + + const leadRowKey = "88001:0:latest:0:1"; + expect(isExplicitNAMCatalogCaptureSelection( + options[1].id, + leadRowKey, + urlOnlyPack, + options[1].model, + )).toBe(true); + expect(isExplicitNAMCatalogCaptureSelection( + leadRowKey, + leadRowKey, + urlOnlyPack, + options[1].model, + )).toBe(true); + expect(isExplicitNAMCatalogCaptureSelection( + "88001:0", + leadRowKey, + urlOnlyPack, + options[1].model, + )).toBe(false); + expect(namCatalogCaptureSelectionKey(urlOnlyPack, options[1].model)).toBe(options[1].id); + }); + + it("uses child RAW/IR labels ahead of broad parent gear metadata", () => { + expect(captureTypeForToneModel(pack, pack.models![0])).toBe("amp"); + expect(captureTypeForToneModel(pack, pack.models![1])).toBe("amp_cab"); + expect(captureTypeForToneModel(pack, { + ...pack.models![1], + metadata: { gear_type: "amp" }, + })).toBe("amp"); + }); + + it("collapses hydrated children to one pack row without losing order", () => { + const rows = [ + { key: "67139:6713901", tone: pack }, + { key: "67139:6713902", tone: pack }, + { key: "88:1", tone: { id: 88, title: "Other" } }, + ]; + expect(collapseNAMCatalogRowsToTonePacks(rows).map(({ key }) => key)).toEqual([ + "67139:6713901", + "88:1", + ]); + }); + + it("parses stable tone/model selection keys and treats pack-only selection as non-explicit", () => { + expect(selectedNAMCatalogIdentity("67139:6713902:latest:0:1")).toEqual({ toneId: 67139, modelId: 6713902 }); + expect(selectedNAMCatalogIdentity("67139:0")).toEqual({ toneId: 67139, modelId: 0 }); + expect(selectedNAMCatalogIdentity("")).toEqual({ toneId: 0, modelId: 0 }); + }); +}); diff --git a/frontend/src/__tests__/namToneSaveMetadata.test.ts b/frontend/src/__tests__/namToneSaveMetadata.test.ts new file mode 100644 index 0000000..33fc37a --- /dev/null +++ b/frontend/src/__tests__/namToneSaveMetadata.test.ts @@ -0,0 +1,472 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + NAM_PRESET_SAVE_COPY, + buildNAMToneSaveDraft, + makeNAMActivePreview, + normalizeNAMActivePreview, + saveDraftToMetadata, + saveNAMTone, +} from "../components/NAMToneSave"; +import { nativeBridge, type BuiltInPluginSchema, type NAMInstalledModel } from "../services/NativeBridge"; + +const schema: BuiltInPluginSchema = { + schemaVersion: 1, + name: "OpenStudio NAM Rack", + category: "Built-in", + chain: "input", + fxIndex: 0, + parameters: [ + { + id: "inputTrimDb", + label: "Input", + type: "continuous", + value: 0, + min: -24, + max: 24, + defaultValue: 0, + unit: "dB", + }, + ], + modelState: { + ampModelPath: "OpenStudio/NAM/previews/classic-crunch-a2.nam", + pedalModelPath: "", + cabIRPath: "", + }, + uiState: {}, +}; + +const address = { + trackId: "track-1", + chain: "input" as const, + fxIndex: 0, +}; + +describe("NAM tone save metadata", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("presents the full rack as a NAM Preset", () => { + expect(NAM_PRESET_SAVE_COPY).toMatchObject({ + title: "Save NAM Preset", + action: "Save Preset", + eyebrow: "Complete rack preset", + nameLabel: "Preset Name", + }); + expect(NAM_PRESET_SAVE_COPY.description).toContain("Captures, IR"); + expect(NAM_PRESET_SAVE_COPY.description).toContain("effect settings"); + }); + + it("builds a draft from active preview metadata before saved/path fallbacks", () => { + const draft = buildNAMToneSaveDraft({ + schema: { + ...schema, + uiState: { + namSavedTone: { + title: "Saved fallback", + creator: "Saved Creator", + }, + }, + }, + activePreview: { + schemaVersion: 1, + slot: "amp", + title: "Preview Crunch", + modelName: "Classic Crunch A2", + creator: "ToneDev", + localPath: "OpenStudio/NAM/previews/classic-crunch-a2.nam", + }, + }); + + expect(draft.toneName).toBe("Preview Crunch"); + expect(draft.creator).toBe("ToneDev"); + }); + + it("deduplicates preset tags case-insensitively when building and saving a draft", () => { + const draft = buildNAMToneSaveDraft({ + title: "Tag Guard", + tags: ["amp", "Clean", "AMP", "clean", "cab"], + }); + + expect(draft.tags).toEqual(["AMP", "clean", "cab"]); + expect(saveDraftToMetadata({ + ...draft, + tagsText: "amp, Clean, AMP, clean, cab", + }).tags).toEqual(["AMP", "clean", "cab"]); + }); + + it("round-trips requested Cab intent independently from the effective preview baseline", () => { + const normalized = normalizeNAMActivePreview({ + slot: "amp", + localPath: "OpenStudio/NAM/previews/full-rig.nam", + baseline: { + pedalModelPath: "", + ampModelPath: "OpenStudio/NAM/library/amp.nam", + cabIRPath: "OpenStudio/NAM/library/cab.wav", + ampModelSize: 0.42, + pedalDeclaredCaptureType: "unknown", + ampDeclaredCaptureType: "full-rig", + cabEnabled: 0, + cabRequestedEnabled: true, + pedalMix: 0, + ampEnabled: 0, + ampMix: 0, + }, + }); + + expect(normalized?.baseline).toMatchObject({ + cabEnabled: 0, + cabRequestedEnabled: true, + pedalDeclaredCaptureType: "unknown", + ampDeclaredCaptureType: "full_rig", + ampModelSize: 0.42, + ampEnabled: 0, + ampMix: 0, + }); + }); + + it("commits preview downloads and persists saved tone identity", async () => { + const previewRecord: NAMInstalledModel = { + modelId: 5320302, + toneId: 53203, + name: "Classic Crunch A2", + toneTitle: "Classic Crunch", + creator: "Scott M.", + localPath: "OpenStudio/NAM/previews/classic-crunch-a2.nam", + sourceUrl: "https://www.tone3000.com/tones/53203", + license: "Free", + preview: true, + }; + const committedRecord: NAMInstalledModel = { + ...previewRecord, + preview: false, + localPath: "OpenStudio/NAM/library/classic-crunch-a2.nam", + }; + const activePreview = makeNAMActivePreview(previewRecord, { + key: "53203:5320302", + slot: "amp", + toneId: 53203, + modelId: 5320302, + title: "Classic Crunch", + modelName: "Classic Crunch A2", + creator: "Scott M.", + localPath: previewRecord.localPath, + previousPath: "", + source: "catalog", + previewDownload: true, + saved: false, + action: "live-preview", + sourceUrl: previewRecord.sourceUrl, + license: previewRecord.license, + }); + const setState = vi.spyOn(nativeBridge, "setBuiltInPluginState").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setBuiltInPluginParam").mockResolvedValue(true); + vi.spyOn(nativeBridge, "getBuiltInPluginState").mockResolvedValue({ + values: { + inputTrimDb: 1.5, + auditionSource: 1, + reverbEnabled: 1, + reverbDecaySec: 9.25, + reverbShimmer: 0.42, + }, + modelState: { + ampModelPath: previewRecord.localPath, + ampModelSize: 0.75, + pedalModelPath: "OpenStudio/NAM/library/drive.nam", + pedalModelSize: 0.5, + cabIRPath: "OpenStudio/IR/library/studio.wav", + cabRequestedEnabled: true, + }, + dspState: { + reverbEngineVersion: 5, + namEffectsDspVersion: 19, + }, + uiState: { + namActivePreview: activePreview, + namPresetDirty: true, + namActivePresetName: "Earlier Preset", + namPresetBaseline: { values: { reverbDecaySec: 2.2 } }, + namRackSlots: { + order: ["gate", "pedal", "amp", "cab", "delay", "reverb", "mod", "eq"], + }, + }, + }); + const commit = vi.spyOn(nativeBridge, "commitNAMPreviewTone").mockResolvedValue({ + success: true, + record: committedRecord, + }); + const savePreset = vi.spyOn(nativeBridge, "saveBuiltInFXPreset").mockResolvedValue(true); + + const result = await saveNAMTone({ + address, + schema, + metadata: { + toneName: "My Saved Crunch", + creator: "Session User", + sourceUrl: previewRecord.sourceUrl, + license: "Free", + }, + activePreview, + selectedRecord: previewRecord, + slotHint: "amp", + sourceIds: { + toneId: 53203, + modelId: 5320302, + }, + }); + + expect(result.success).toBe(true); + expect(result.committed).toBe(true); + expect(commit).toHaveBeenCalledWith(previewRecord, expect.objectContaining({ + toneName: "My Saved Crunch", + creator: "Session User", + }), expect.objectContaining({ + dspState: { + reverbEngineVersion: 5, + namEffectsDspVersion: 19, + }, + })); + expect(result.savedTone?.title).toBe("My Saved Crunch"); + expect(result.savedTone?.captureTitle).toBe("Classic Crunch"); + expect(result.savedTone?.modelName).toBe("Classic Crunch A2"); + expect(result.savedTone?.creator).toBe("Session User"); + expect(result.savedTone?.localPath).toBe(committedRecord.localPath); + expect(result.savedTone?.toneId).toBe(53203); + expect(result.savedTone?.modelId).toBe(5320302); + expect(result.savedTone?.rackState).toMatchObject({ + values: { + reverbEnabled: 1, + reverbDecaySec: 9.25, + reverbShimmer: 0.42, + }, + modelState: { + pedalModelPath: "OpenStudio/NAM/library/drive.nam", + pedalModelSize: 0.5, + ampModelPath: committedRecord.localPath, + ampModelSize: 0.75, + cabIRPath: "OpenStudio/IR/library/studio.wav", + cabRequestedEnabled: true, + }, + dspState: { + reverbEngineVersion: 5, + namEffectsDspVersion: 19, + }, + slotOrder: ["gate", "pedal", "amp", "cab", "delay", "reverb", "mod", "eq"], + }); + expect(result.savedTone?.values).not.toHaveProperty("auditionSource"); + expect(setState).toHaveBeenCalledWith(address, expect.objectContaining({ + modelState: expect.objectContaining({ + ampModelPath: committedRecord.localPath, + ampDeclaredCaptureType: "unknown", + }), + uiState: expect.objectContaining({ + namActivePreview: null, + namPresetDirty: false, + namActivePresetName: null, + namPresetBaseline: null, + namSavedTone: expect.objectContaining({ + title: "My Saved Crunch", + localPath: committedRecord.localPath, + }), + }), + })); + const finalStateWriteOrder = setState.mock.invocationCallOrder[setState.mock.invocationCallOrder.length - 1]; + expect(finalStateWriteOrder).toBeLessThan(savePreset.mock.invocationCallOrder[0]); + }); + + it("persists committed Cab/IR previews into the cab slot", async () => { + const cabSchema: BuiltInPluginSchema = { + ...schema, + modelState: { + ampModelPath: "OpenStudio/NAM/library/amp.nam", + pedalModelPath: "", + cabIRPath: "OpenStudio/NAM/previews/bright-room.wav", + }, + }; + const previewRecord: NAMInstalledModel = { + modelId: 7002, + toneId: 700, + name: "Bright Room IR", + toneTitle: "Bright Room", + creator: "IR Maker", + gear: "Cab", + gearType: "Cabinet IR", + localPath: "OpenStudio/NAM/previews/bright-room.wav", + preview: true, + }; + const committedRecord: NAMInstalledModel = { + ...previewRecord, + preview: false, + localPath: "OpenStudio/NAM/library/bright-room.wav", + }; + const activePreview = makeNAMActivePreview(previewRecord, { + key: "700:7002", + slot: "cab", + toneId: 700, + modelId: 7002, + title: "Bright Room", + modelName: "Bright Room IR", + creator: "IR Maker", + localPath: previewRecord.localPath, + source: "catalog", + previewDownload: true, + saved: false, + action: "live-preview", + }); + const setState = vi.spyOn(nativeBridge, "setBuiltInPluginState").mockResolvedValue(true); + vi.spyOn(nativeBridge, "getBuiltInPluginState").mockResolvedValue({ + values: { + inputTrimDb: 0, + auditionSource: 1, + }, + modelState: cabSchema.modelState, + uiState: { + namActivePreview: activePreview, + }, + }); + vi.spyOn(nativeBridge, "commitNAMPreviewTone").mockResolvedValue({ + success: true, + record: committedRecord, + }); + vi.spyOn(nativeBridge, "saveBuiltInFXPreset").mockResolvedValue(true); + + const result = await saveNAMTone({ + address, + schema: cabSchema, + metadata: { + toneName: "My Cab Tone", + creator: "Session User", + }, + activePreview, + selectedRecord: previewRecord, + slotHint: "cab", + }); + + expect(result.success).toBe(true); + expect(result.savedTone?.slot).toBe("cab"); + expect(result.savedTone?.slots.cab).toBe(committedRecord.localPath); + expect(setState).toHaveBeenCalledWith(address, expect.objectContaining({ + modelState: { + cabIRPath: committedRecord.localPath, + }, + uiState: expect.objectContaining({ + namSavedTone: expect.objectContaining({ + slot: "cab", + localPath: committedRecord.localPath, + }), + }), + })); + }); + + it("saves a full rig without erasing the user's requested external-Cab preference", async () => { + const fullRigRecord: NAMInstalledModel = { + modelId: 8102, + toneId: 810, + name: "Studio Full Rig", + toneTitle: "Studio Full Rig", + creator: "Rig Maker", + gearType: "full-rig", + captureType: "full_rig", + localPath: "OpenStudio/NAM/library/studio-full-rig.nam", + }; + const activePreview = makeNAMActivePreview(fullRigRecord, { + key: "810:8102", + slot: "amp", + localPath: fullRigRecord.localPath, + source: "installed", + saved: false, + action: "live-preview", + captureType: "full_rig", + includesCab: true, + }); + const currentState = { + values: { + inputTrimDb: 0, + auditionSource: 0, + cabEnabled: 0, + ampEnabled: 1, + ampMix: 1, + }, + modelState: { + ampModelPath: fullRigRecord.localPath, + pedalModelPath: "", + cabIRPath: "OpenStudio/NAM/library/retained-cab.wav", + hasAmpModel: true, + ampIncludesCab: true, + cabRequestedEnabled: true, + }, + uiState: { + namActivePreview: activePreview, + }, + }; + vi.spyOn(nativeBridge, "getBuiltInPluginState").mockResolvedValue(currentState); + const setParam = vi.spyOn(nativeBridge, "setBuiltInPluginParam").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setBuiltInPluginState").mockResolvedValue(true); + vi.spyOn(nativeBridge, "saveBuiltInFXPreset").mockResolvedValue(true); + + const result = await saveNAMTone({ + address, + schema, + metadata: { + toneName: "Full Rig Preset", + creator: "Session User", + }, + activePreview, + selectedRecord: fullRigRecord, + slotHint: "amp", + }); + + expect(result.success).toBe(true); + expect(result.savedTone?.modelState.cabRequestedEnabled).toBe(true); + expect(result.savedTone?.modelState.ampDeclaredCaptureType).toBe("full_rig"); + expect(result.savedTone?.values.cabEnabled).toBe(0); + expect(setParam).toHaveBeenCalledWith(address, "auditionSource", 0); + expect(setParam).not.toHaveBeenCalledWith(address, "cabEnabled", expect.any(Number)); + }); + + it("reports failure when the native preset cannot be saved for reopen", async () => { + vi.spyOn(nativeBridge, "getBuiltInPluginState").mockResolvedValue({ + values: { + inputTrimDb: 0, + auditionSource: 1, + pedalMix: 0.35, + }, + modelState: schema.modelState, + uiState: { + namActivePreview: { slot: "amp", saved: false }, + }, + }); + vi.spyOn(nativeBridge, "setBuiltInPluginParam").mockResolvedValue(true); + const setState = vi.spyOn(nativeBridge, "setBuiltInPluginState").mockResolvedValue(true); + vi.spyOn(nativeBridge, "saveBuiltInFXPreset").mockResolvedValue(false); + + const result = await saveNAMTone({ + address, + schema, + metadata: { + toneName: "Reopen Guard", + creator: "Session User", + }, + selectedRecord: null, + slotHint: "amp", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Preset could not be saved"); + expect(result.error).toContain("previous rack was restored"); + expect(setState).toHaveBeenLastCalledWith(address, { + values: { + inputTrimDb: 0, + pedalMix: 0.35, + }, + modelState: { + clearPedalModel: true, + ampModelPath: "OpenStudio/NAM/previews/classic-crunch-a2.nam", + clearCabIR: true, + }, + uiState: { + namActivePreview: { slot: "amp", saved: false }, + }, + }); + }); +}); diff --git a/frontend/src/__tests__/namTunerTelemetry.test.ts b/frontend/src/__tests__/namTunerTelemetry.test.ts new file mode 100644 index 0000000..1f2f388 --- /dev/null +++ b/frontend/src/__tests__/namTunerTelemetry.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { AudioDebugSnapshot } from "../services/NativeBridge"; +import { + isNAMTunerTelemetryForRack, + resolveNAMTunerDisplayPitch, + startNAMTunerSubscription, +} from "../utils/namTunerTelemetry"; + +const snapshot = (overrides: Partial = {}): AudioDebugSnapshot => ({ + transportPlaying: false, + transportRecording: false, + transportPosition: 0, + sampleRate: 48000, + blockSize: 128, + playbackClipCount: 0, + activeOutputChannels: 2, + postTrackPlaybackPeak: 0, + postMonitoringInputPeak: 0, + postMasterFxPeak: 0, + postMonitoringFxPeak: 0, + finalOutputPeak: 0, + lastRecordingClipCountReturned: 0, + playbackTracks: [], + tunerInputStartChannel: 0, + tunerInputChannelCount: 1, + ...overrides, +}); + +const hostTrack = { + type: "audio", + inputStartChannel: 0, + inputChannelCount: 1, + monitorEnabled: true, +}; + +describe("NAM tuner telemetry routing", () => { + it("accepts telemetry selected for this host track", () => { + expect(isNAMTunerTelemetryForRack({ + snapshot: snapshot({ + tunerTrackId: "track-a", + tunerInputStartChannel: 7, + tunerInputChannelCount: 2, + }), + hostTrack, + hasTrackAddress: true, + trackId: "track-a", + })).toBe(true); + }); + + it("rejects telemetry from another track even when both share an input", () => { + expect(isNAMTunerTelemetryForRack({ + snapshot: snapshot({ tunerTrackId: "track-b" }), + hostTrack, + hasTrackAddress: true, + trackId: "track-a", + })).toBe(false); + }); + + it("falls back to input-route matching for older native snapshots", () => { + expect(isNAMTunerTelemetryForRack({ + snapshot: snapshot(), + hostTrack, + hasTrackAddress: true, + trackId: "track-a", + })).toBe(true); + }); + + it("keeps global master telemetry separate from track tuners", () => { + const globalSnapshot = snapshot({ + tunerUsesGlobalInput: true, + tunerTrackId: "", + }); + + expect(isNAMTunerTelemetryForRack({ + snapshot: globalSnapshot, + hostTrack: null, + hasTrackAddress: false, + })).toBe(true); + expect(isNAMTunerTelemetryForRack({ + snapshot: globalSnapshot, + hostTrack, + hasTrackAddress: true, + trackId: "track-a", + })).toBe(false); + }); + + it("keeps the averaged note visible while native telemetry is holding", () => { + const display = resolveNAMTunerDisplayPitch(snapshot({ + tunerState: "Holding", + tunerPitchLocked: true, + tunerFrequencyHz: 81.9, + tunerAverageFrequencyHz: 82.4069, + tunerCents: -10, + tunerAverageCents: 1.5, + })); + + expect(display).toEqual({ + state: "holding", + frequencyHz: 82.4069, + cents: 1.5, + stateHasDisplayPitch: true, + hasDisplayPitch: true, + }); + }); + + it("serializes rapid activation cleanup and deactivates a subscriber once", async () => { + const calls: Array<{ trackId: string; active: boolean; subscriberId: string }> = []; + let finishActivation: ((active: boolean) => void) | undefined; + const activationGate = new Promise((resolve) => { + finishActivation = resolve; + }); + const bridge = { + setNAMTunerActive: async (trackId: string, active: boolean, subscriberId: string) => { + calls.push({ trackId, active, subscriberId }); + return active ? activationGate : true; + }, + }; + + const subscription = startNAMTunerSubscription( + bridge, + "track-a", + "subscriber-a", + ); + const firstDisposal = subscription.dispose(); + const secondDisposal = subscription.dispose(); + + expect(firstDisposal).toBe(secondDisposal); + expect(calls).toEqual([ + { trackId: "track-a", active: true, subscriberId: "subscriber-a" }, + ]); + + finishActivation?.(true); + await firstDisposal; + + expect(calls).toEqual([ + { trackId: "track-a", active: true, subscriberId: "subscriber-a" }, + { trackId: "track-a", active: false, subscriberId: "subscriber-a" }, + ]); + }); +}); diff --git a/frontend/src/__tests__/nativePath.test.ts b/frontend/src/__tests__/nativePath.test.ts new file mode 100644 index 0000000..4fcc6d7 --- /dev/null +++ b/frontend/src/__tests__/nativePath.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { joinNativePath } from "../utils/nativePath"; + +describe("joinNativePath", () => { + it("preserves native Windows and UNC separators", () => { + expect(joinNativePath("C:\\Renders", "mix.wav")).toBe("C:\\Renders\\mix.wav"); + expect(joinNativePath("\\\\server\\share\\", "stem.wav")) + .toBe("\\\\server\\share\\stem.wav"); + }); + + it("uses POSIX separators for macOS/Linux and forward-slash Windows paths", () => { + expect(joinNativePath("/home/user/renders/", "mix.wav")) + .toBe("/home/user/renders/mix.wav"); + expect(joinNativePath("C:/Users/example/Renders", "mix.wav")) + .toBe("C:/Users/example/Renders/mix.wav"); + }); + + it("handles an empty directory and strips accidental leading separators", () => { + expect(joinNativePath("", "/mix.wav")).toBe("mix.wav"); + expect(joinNativePath("/tmp/", "\\mix.wav")).toBe("/tmp/mix.wav"); + }); +}); diff --git a/frontend/src/__tests__/nativeShortcutForwarding.test.ts b/frontend/src/__tests__/nativeShortcutForwarding.test.ts new file mode 100644 index 0000000..ae203e9 --- /dev/null +++ b/frontend/src/__tests__/nativeShortcutForwarding.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import pluginWindowManagerSource from "../../../Source/PluginWindowManager.cpp?raw"; + +describe("native plug-in shortcut forwarding", () => { + it("preserves distinct editing, navigation, function, and numpad keys", () => { + expect(pluginWindowManagerSource).toContain('backspaceKey) return "Backspace"'); + expect(pluginWindowManagerSource).toContain('pageUpKey) return "PageUp"'); + expect(pluginWindowManagerSource).toContain('homeKey) return "Home"'); + expect(pluginWindowManagerSource).toContain('F12Key) return "F12"'); + expect(pluginWindowManagerSource).toContain('numberPad0, "0", "Numpad0"'); + expect(pluginWindowManagerSource).toContain('numberPadAdd, "+", "NumpadAdd"'); + }); + + it("does not report macOS Command as both Ctrl and Meta", () => { + expect(pluginWindowManagerSource).toContain("#if JUCE_MAC"); + expect(pluginWindowManagerSource).toContain( + 'obj->setProperty("ctrlKey", modifiers.isCtrlDown());', + ); + expect(pluginWindowManagerSource).toContain( + 'obj->setProperty("metaKey", modifiers.isCommandDown());', + ); + expect(pluginWindowManagerSource).not.toContain( + 'obj->setProperty("ctrlKey", modifiers.isCtrlDown() || modifiers.isCommandDown());', + ); + }); +}); diff --git a/frontend/src/__tests__/parameterWheel.test.ts b/frontend/src/__tests__/parameterWheel.test.ts new file mode 100644 index 0000000..59e9caf --- /dev/null +++ b/frontend/src/__tests__/parameterWheel.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + accumulateParameterWheelGesture, + getParameterWheelStepCount, + getParameterWheelValue, +} from "../utils/parameterWheel"; +import { createWheelDeltaAccumulator } from "../utils/wheelDeltaAccumulator"; + +describe("parameter wheel values", () => { + it("moves one declared step per conventional mouse notch", () => { + expect(getParameterWheelValue( + { operation: "adjust", amount: -100, precision: "normal" }, + { min: 0, max: 10, value: 5, step: 0.5 }, + )).toBe(5.5); + }); + + it("uses one tenth of the normal step in fine mode", () => { + expect(getParameterWheelValue( + { operation: "adjust", amount: 100, precision: "fine" }, + { min: -1, max: 1, value: 0, step: 0.1 }, + )).toBe(-0.01); + }); + + it("keeps tiny packets fractional and preserves exact normal/fine rates", () => { + expect(getParameterWheelValue( + { operation: "adjust", amount: -1, precision: "normal" }, + { min: 0, max: 10, value: 5, step: 0.5 }, + )).toBe(5.005); + expect(getParameterWheelValue( + { operation: "adjust", amount: -1, precision: "fine" }, + { min: 0, max: 10, value: 5, step: 0.5 }, + )).toBe(5.0005); + }); + + it("scales larger deltas and clamps both ends", () => { + expect(getParameterWheelValue( + { operation: "adjust", amount: -800, precision: "normal" }, + { min: 0, max: 1, value: 0.8, step: 0.1 }, + )).toBe(1); + expect(getParameterWheelValue( + { operation: "adjust", amount: 800, precision: "normal" }, + { min: 0, max: 1, value: 0.2, step: 0.1 }, + )).toBe(0); + }); + + it("is a no-op for suppress/native gestures and zero deltas", () => { + expect(getParameterWheelValue( + { operation: "suppress", amount: -100, precision: "normal" }, + { min: 0, max: 1, value: 0.5 }, + )).toBe(0.5); + }); +}); + +describe("parameter wheel step counts", () => { + it("derives direction and precision from the resolved gesture", () => { + expect(getParameterWheelStepCount( + { operation: "adjust", amount: -100, precision: "normal" }, + )).toBe(4); + expect(getParameterWheelStepCount( + { operation: "adjust", amount: 100, precision: "fine" }, + )).toBe(-1); + }); + + it("supports control-specific rates while retaining resolver precision", () => { + expect(getParameterWheelStepCount( + { operation: "adjust", amount: -100, precision: "normal" }, + { normal: 2, fine: 1 }, + )).toBe(2); + expect(getParameterWheelStepCount( + { operation: "adjust", amount: -100, precision: "fine" }, + { normal: 2, fine: 1 }, + )).toBe(1); + }); + + it("scales normalized amounts and rejects non-adjustment gestures", () => { + expect(getParameterWheelStepCount( + { operation: "adjust", amount: 250, precision: "normal" }, + )).toBe(-10); + expect(getParameterWheelStepCount( + { operation: "native-scroll", amount: -100, precision: "fine" }, + )).toBe(0); + expect(getParameterWheelStepCount( + { operation: "adjust", amount: 0, precision: "normal" }, + )).toBe(0); + }); + + it("accumulates sub-pixel gesture packets per parameter target", () => { + const accumulator = createWheelDeltaAccumulator({ quantum: 1 }); + const gesture = { + profileId: "openstudio", + ruleId: "parameter.adjust", + matched: true, + operation: "adjust" as const, + target: "parameter" as const, + axis: "vertical" as const, + amount: -0.25, + delta: { x: 0, y: -0.25, mode: "pixel" as const, sourceMode: 0, isZero: false }, + modifiers: { + primary: false, + secondary: false, + alt: false, + shift: false, + raw: { control: false, commandOrMeta: false, altOrOption: false, shift: false }, + }, + device: { device: "trackpad" as const, basis: "fractional-pixel-delta" as const }, + anchor: { kind: "hovered-control" as const }, + precision: "normal" as const, + preventDefault: true, + stopPropagation: true, + }; + + expect(accumulateParameterWheelGesture(accumulator, gesture, "gain")).toBeNull(); + expect(accumulateParameterWheelGesture(accumulator, gesture, "gain")).toBeNull(); + expect(accumulateParameterWheelGesture(accumulator, gesture, "gain")).toBeNull(); + expect(accumulateParameterWheelGesture(accumulator, gesture, "gain")?.amount).toBe(-1); + accumulator.dispose(); + }); +}); diff --git a/frontend/src/__tests__/pitchEditorLockSafety.test.ts b/frontend/src/__tests__/pitchEditorLockSafety.test.ts new file mode 100644 index 0000000..119b9ed --- /dev/null +++ b/frontend/src/__tests__/pitchEditorLockSafety.test.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PitchCorrectionCompletionData, PitchNoteData } from "../services/NativeBridge"; +import { nativeBridge } from "../services/NativeBridge"; +import { + handlePitchCorrectionComplete, + usePitchEditorStore, +} from "../store/pitchEditorStore"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; +import { + getRegisteredAction, + registerScopedActionExecutor, +} from "../store/actionRegistry"; +import { getPitchEditorEditActionBlockResult } from "../components/PitchEditorLowerZone"; +import { + activateShortcutContext, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; + +const originalDAWState = { + tracks: useDAWStore.getState().tracks, + globalLocked: useDAWStore.getState().globalLocked, + lockSettings: useDAWStore.getState().lockSettings, + showPitchEditor: useDAWStore.getState().showPitchEditor, + pitchEditorTrackId: useDAWStore.getState().pitchEditorTrackId, + pitchEditorClipId: useDAWStore.getState().pitchEditorClipId, + pitchEditorFxIndex: useDAWStore.getState().pitchEditorFxIndex, + syncClipsWithBackend: useDAWStore.getState().syncClipsWithBackend, +}; + +const cleanups: Array<() => void> = []; + +function makeNote(id: string, detectedPitch: number, startTime: number): PitchNoteData { + return { + id, + startTime, + endTime: startTime + 0.4, + effectiveStartTime: startTime, + effectiveEndTime: startTime + 0.4, + detectedPitch, + correctedPitch: detectedPitch, + driftCorrectionAmount: 0, + vibratoDepth: 1, + vibratoRate: 0, + transitionIn: 40, + transitionOut: 60, + formantShift: 0, + gain: 0, + voiced: true, + wordGroupId: `word-${id}`, + pitchDrift: [], + }; +} + +function setupEditablePitchTarget() { + const track = { + ...createDefaultTrack("track-a", "Track A"), + clips: [{ + id: "clip-a", + filePath: "C:/authoritative.wav", + name: "Audio", + startTime: 0, + duration: 4, + offset: 0, + color: "#123456", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + locked: false, + }], + }; + useDAWStore.setState({ + tracks: [track], + globalLocked: false, + lockSettings: { ...useDAWStore.getState().lockSettings, items: false }, + showPitchEditor: true, + pitchEditorTrackId: "track-a", + pitchEditorClipId: "clip-a", + pitchEditorFxIndex: -1, + syncClipsWithBackend: vi.fn(async () => undefined), + }); + usePitchEditorStore.getState().open("track-a", "clip-a", -1); + usePitchEditorStore.setState({ + notes: [makeNote("note-a", 60, 0.2), makeNote("note-b", 64, 1.0)], + selectedNoteIds: ["note-a"], + contour: null, + undoStack: [], + redoStack: [], + applyState: "idle", + applyMessage: "", + renderCoverage: [], + }); +} + +function setEditLock(kind: "global" | "items" | "clip", locked: boolean) { + if (kind === "global") { + useDAWStore.setState({ globalLocked: locked }); + return; + } + if (kind === "items") { + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, items: locked }, + })); + return; + } + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id === "track-a" + ? { + ...track, + clips: track.clips.map((clip) => clip.id === "clip-a" + ? { ...clip, locked } + : clip), + } + : track), + })); +} + +function setTrackFrozen(frozen: boolean) { + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id === "track-a" + ? { ...track, frozen } + : track), + })); +} + +function currentClipFilePath() { + return useDAWStore.getState().tracks[0]?.clips[0]?.filePath; +} + +async function flushPromises() { + await Promise.resolve(); + await Promise.resolve(); +} + +function completion(requestId: string, outputFile: string): PitchCorrectionCompletionData { + return { + clipId: "clip-a", + requestId, + outputFile, + success: true, + renderMode: "note_hq", + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + location: { hostname: "test.local" }, + setTimeout, + clearTimeout, + localStorage: { getItem: () => null }, + }); + vi.spyOn(nativeBridge, "applyPitchCorrection").mockResolvedValue({ outputFile: "", success: true }); + vi.spyOn(nativeBridge, "previewPitchCorrection").mockResolvedValue({ outputFile: "", success: true }); + vi.spyOn(nativeBridge, "cancelPitchCorrectionRequests").mockResolvedValue(true); + vi.spyOn(nativeBridge, "clearAllPitchPreviewRoutes").mockResolvedValue(true); + vi.spyOn(nativeBridge, "clearClipRenderedPreviewSegments").mockResolvedValue(true); + vi.spyOn(nativeBridge, "clearClipPitchPreview").mockResolvedValue(true); + vi.spyOn(nativeBridge, "stopPitchScrubPreview").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setPitchCorrectionBypass").mockResolvedValue(undefined); + vi.spyOn(nativeBridge, "getPitchPreviewRoutingStatus").mockResolvedValue({ + monitorMode: "corrected_source", + correctedSourceActive: true, + renderedSegmentActive: false, + clipLivePreviewActive: false, + scrubPreviewActive: false, + }); + setupEditablePitchTarget(); +}); + +afterEach(() => { + while (cleanups.length > 0) cleanups.pop()?.(); + resetShortcutContextForTests(); + usePitchEditorStore.getState().close(); + useDAWStore.setState(originalDAWState); + vi.clearAllTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("pitch editor lock and freeze authority", () => { + it("reverts an active drag and cancels its preview without creating an apply", async () => { + const applySpy = vi.mocked(nativeBridge.applyPitchCorrection); + const cancelSpy = vi.mocked(nativeBridge.cancelPitchCorrectionRequests); + + usePitchEditorStore.getState().pushUndo("Move note"); + usePitchEditorStore.getState().beginInteractivePreview("note-a"); + usePitchEditorStore.getState().updateNote("note-a", { correctedPitch: 67 }); + expect(usePitchEditorStore.getState().notes[0].correctedPitch).toBe(67); + + setEditLock("global", true); + expect(usePitchEditorStore.getState().notes[0].correctedPitch).toBe(60); + expect(usePitchEditorStore.getState().undoStack).toEqual([]); + expect(usePitchEditorStore.getState().redoStack).toEqual([]); + expect(cancelSpy).toHaveBeenCalledWith("clip-a", "C:/authoritative.wav"); + + usePitchEditorStore.getState().commitNoteEdit(); + setEditLock("global", false); + await flushPromises(); + await vi.advanceTimersByTimeAsync(1000); + expect(applySpy).not.toHaveBeenCalled(); + }); + + it("blocks every pitch-data mutation and native pitch request while item-locked", async () => { + setEditLock("items", true); + await flushPromises(); + const before = usePitchEditorStore.getState(); + const noteSnapshot = JSON.stringify(before.notes); + + const state = usePitchEditorStore.getState(); + state.beginInteractivePreview("note-a"); + state.pushUndo("Blocked edit"); + state.updateNote("note-a", { correctedPitch: 72 }); + state.commitNoteEdit(); + state.updateSelectedNotes({ gain: 3 }); + state.moveSelectedPitch(1); + state.splitNote("note-a", 0.4); + state.correctSelectedToScale(); + state.correctAllToScale(); + state.setNoteGain("note-a", 4); + state.setNoteModulation("note-a", 20); + state.setNoteDrift("note-a", 50); + state.setNoteTransition("note-a", 100, 100); + state.applyCorrectPitchMacro(1, 1, false); + state.mergeNotes(["note-a", "note-b"]); + state.beginDrawPitch(); + state.drawPitchOnNote("note-a", 0.3, 68); + state.commitDrawPitch(); + state.toggleABCompare(); + await state.applyCorrection(); + await state.previewCorrection(); + + const after = usePitchEditorStore.getState(); + expect(JSON.stringify(after.notes)).toBe(noteSnapshot); + expect(after.undoStack).toEqual(before.undoStack); + expect(after.redoStack).toEqual(before.redoStack); + expect(after.abCompareMode).toBe(false); + expect(nativeBridge.applyPitchCorrection).not.toHaveBeenCalled(); + expect(nativeBridge.previewPitchCorrection).not.toHaveBeenCalled(); + expect(nativeBridge.setPitchCorrectionBypass).not.toHaveBeenCalled(); + }); + + it("claims a docked Pitch Editor shortcut as a locked no-op, then executes it after unlock", async () => { + cleanups.push(registerScopedActionExecutor( + { kind: "pitch_editor" }, + (actionId) => { + const blocked = getPitchEditorEditActionBlockResult(actionId); + if (blocked) return blocked; + if (actionId === "pitch.moveUp") { + usePitchEditorStore.getState().moveSelectedPitch(1); + return "handled"; + } + return "unmatched"; + }, + ["pitch.moveUp"], + )); + activateShortcutContext({ kind: "pitch_editor" }); + setEditLock("clip", true); + await flushPromises(); + + getRegisteredAction("pitch.moveUp")?.execute(); + expect(usePitchEditorStore.getState().notes[0].correctedPitch).toBe(60); + + setEditLock("clip", false); + await flushPromises(); + getRegisteredAction("pitch.moveUp")?.execute(); + expect(usePitchEditorStore.getState().notes[0].correctedPitch).toBe(61); + }); + + for (const lockKind of ["global", "items", "clip"] as const) { + it(`replays committed undo and redo while the ${lockKind} lock remains active`, async () => { + const applySpy = vi.mocked(nativeBridge.applyPitchCorrection); + usePitchEditorStore.getState().moveSelectedPitch(1); + expect(usePitchEditorStore.getState().notes[0].correctedPitch).toBe(61); + expect(usePitchEditorStore.getState().undoStack).toHaveLength(1); + + setEditLock(lockKind, true); + await flushPromises(); + usePitchEditorStore.getState().undo(); + expect(usePitchEditorStore.getState().notes[0].correctedPitch).toBe(60); + expect(usePitchEditorStore.getState().undoStack).toHaveLength(0); + expect(usePitchEditorStore.getState().redoStack).toHaveLength(1); + await vi.advanceTimersByTimeAsync(300); + await flushPromises(); + expect(applySpy).toHaveBeenCalledTimes(1); + + usePitchEditorStore.getState().redo(); + expect(usePitchEditorStore.getState().notes[0].correctedPitch).toBe(61); + expect(usePitchEditorStore.getState().undoStack).toHaveLength(1); + expect(usePitchEditorStore.getState().redoStack).toHaveLength(0); + await vi.advanceTimersByTimeAsync(300); + await flushPromises(); + expect(applySpy).toHaveBeenCalledTimes(2); + }); + } + + it("hides on freeze, preserves history, and restores the editor and history after unfreeze", async () => { + usePitchEditorStore.getState().moveSelectedPitch(1); + expect(usePitchEditorStore.getState().undoStack).toHaveLength(1); + + setTrackFrozen(true); + expect(useDAWStore.getState().showPitchEditor).toBe(false); + const frozenNotes = JSON.stringify(usePitchEditorStore.getState().notes); + usePitchEditorStore.getState().undo(); + expect(JSON.stringify(usePitchEditorStore.getState().notes)).toBe(frozenNotes); + expect(usePitchEditorStore.getState().undoStack).toHaveLength(1); + + setTrackFrozen(false); + await flushPromises(); + expect(useDAWStore.getState().showPitchEditor).toBe(true); + expect(useDAWStore.getState().pitchEditorTrackId).toBe("track-a"); + expect(usePitchEditorStore.getState().undoStack).toHaveLength(1); + usePitchEditorStore.getState().undo(); + expect(usePitchEditorStore.getState().notes[0].correctedPitch).toBe(60); + }); + + for (const authorityLoss of ["lock", "freeze"] as const) { + it(`rejects a stale mid-render ${authorityLoss} completion and accepts a newer request`, async () => { + const applySpy = vi.mocked(nativeBridge.applyPitchCorrection); + const cancelSpy = vi.mocked(nativeBridge.cancelPitchCorrectionRequests); + usePitchEditorStore.getState().moveSelectedPitch(1); + await usePitchEditorStore.getState().applyCorrection(); + const staleRequestId = String(applySpy.mock.calls[applySpy.mock.calls.length - 1]?.[4]); + expect(staleRequestId).not.toBe("undefined"); + + if (authorityLoss === "lock") setEditLock("global", true); + else setTrackFrozen(true); + expect(cancelSpy).toHaveBeenCalledWith("clip-a", "C:/authoritative.wav"); + + handlePitchCorrectionComplete(completion(staleRequestId, "C:/stale.wav")); + expect(currentClipFilePath()).toBe("C:/authoritative.wav"); + + if (authorityLoss === "lock") setEditLock("global", false); + else setTrackFrozen(false); + await flushPromises(); + usePitchEditorStore.getState().moveSelectedPitch(1); + await usePitchEditorStore.getState().applyCorrection(); + const currentRequestId = String(applySpy.mock.calls[applySpy.mock.calls.length - 1]?.[4]); + expect(currentRequestId).not.toBe(staleRequestId); + + handlePitchCorrectionComplete(completion(staleRequestId, "C:/still-stale.wav")); + expect(currentClipFilePath()).toBe("C:/authoritative.wav"); + handlePitchCorrectionComplete(completion(currentRequestId, "C:/current.wav")); + expect(currentClipFilePath()).toBe("C:/current.wav"); + }); + } +}); diff --git a/frontend/src/__tests__/pitchEditorSingleNoteOwnership.test.ts b/frontend/src/__tests__/pitchEditorSingleNoteOwnership.test.ts index 973c5a7..47c4b89 100644 --- a/frontend/src/__tests__/pitchEditorSingleNoteOwnership.test.ts +++ b/frontend/src/__tests__/pitchEditorSingleNoteOwnership.test.ts @@ -1,6 +1,13 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import type { PitchNoteData } from "../services/NativeBridge"; import { usePitchEditorStore } from "../store/pitchEditorStore"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; + +const originalDAWState = { + tracks: useDAWStore.getState().tracks, + globalLocked: useDAWStore.getState().globalLocked, + lockSettings: useDAWStore.getState().lockSettings, +}; function makeNote(id: string, correctedPitch: number, wordGroupId = "word_a"): PitchNoteData { const index = Number(id.replace(/\D/g, "")) || 0; @@ -31,6 +38,26 @@ describe("pitch editor single-note ownership", () => { vi.stubGlobal("window", { location: { hostname: "test.local" }, }); + useDAWStore.setState({ + tracks: [{ + ...createDefaultTrack("track-a", "Track A"), + clips: [{ + id: "clip-a", + filePath: "C:/audio.wav", + name: "Audio", + startTime: 0, + duration: 3, + offset: 0, + color: "#123456", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + locked: false, + }], + }], + globalLocked: false, + lockSettings: { ...useDAWStore.getState().lockSettings, items: false }, + }); usePitchEditorStore.setState({ trackId: "track-a", clipId: "clip-a", @@ -54,6 +81,7 @@ describe("pitch editor single-note ownership", () => { afterEach(() => { vi.clearAllTimers(); + useDAWStore.setState(originalDAWState); vi.unstubAllGlobals(); vi.useRealTimers(); }); diff --git a/frontend/src/__tests__/profileShortcutCollisionDispatch.test.ts b/frontend/src/__tests__/profileShortcutCollisionDispatch.test.ts new file mode 100644 index 0000000..2765c1b --- /dev/null +++ b/frontend/src/__tests__/profileShortcutCollisionDispatch.test.ts @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createDefaultTrack, + useDAWStore, +} from "../store/useDAWStore"; +import { + getActionShortcutScopes, + getRegisteredActions, + registerScopedActionExecutor, +} from "../store/actionRegistry"; +import { + dispatchGlobalShortcut, + getEffectiveActionShortcuts, + resolveRegistryShortcutAction, + type GlobalShortcutPayload, +} from "../utils/globalShortcutDispatcher"; +import { + shortcutBindingEventSignature, + type ShortcutPlatform, +} from "../utils/platform"; +import { + KEYBOARD_SHORTCUT_PROFILES, + getProfileActionBindings, + type KeyboardShortcutProfileId, +} from "../utils/shortcutProfiles"; +import { + activateShortcutContext, + resetShortcutContextForTests, + type EditShortcutContext, +} from "../utils/shortcutContext"; + +const TEST_PLATFORMS = ["macos", "windows"] as const satisfies readonly ShortcutPlatform[]; + +function dispatchAction( + profileId: KeyboardShortcutProfileId, + platform: ShortcutPlatform, + context: EditShortcutContext, + event: GlobalShortcutPayload, +): { handled: boolean; actionIds: string[]; prevented: boolean } { + useDAWStore.setState({ keyboardShortcutProfileId: profileId, customShortcuts: {} }); + resetShortcutContextForTests(); + activateShortcutContext(context); + const actionIds: string[] = []; + const preventDefault = vi.fn(); + const handled = dispatchGlobalShortcut( + { ...event, preventDefault }, + platform, + { executeAction: (action) => actionIds.push(action.id) }, + ); + return { handled, actionIds, prevented: preventDefault.mock.calls.length > 0 }; +} + +describe("profile shortcut collision dispatch", () => { + const scopedCleanups: Array<() => void> = []; + const original = { + keyboardShortcutProfileId: useDAWStore.getState().keyboardShortcutProfileId, + customShortcuts: useDAWStore.getState().customShortcuts, + tracks: useDAWStore.getState().tracks, + selectedTrackId: useDAWStore.getState().selectedTrackId, + selectedTrackIds: useDAWStore.getState().selectedTrackIds, + selectedClipId: useDAWStore.getState().selectedClipId, + selectedClipIds: useDAWStore.getState().selectedClipIds, + transport: useDAWStore.getState().transport, + stepInputEnabled: useDAWStore.getState().stepInputEnabled, + }; + + beforeEach(() => { + const track = createDefaultTrack("profile-dispatch-track", "Profile Dispatch", "#14b8a6", "audio", []); + track.clips = [{ + id: "profile-crossing-clip", + filePath: "C:/profile/crossing.wav", + name: "Crossing", + startTime: 0, + duration: 2, + offset: 0, + color: "#14b8a6", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }]; + useDAWStore.setState((state) => ({ + tracks: [track], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + selectedClipId: "profile-crossing-clip", + selectedClipIds: ["profile-crossing-clip"], + transport: { ...state.transport, currentTime: 1 }, + stepInputEnabled: false, + customShortcuts: {}, + })); + resetShortcutContextForTests(); + }); + + afterEach(() => { + scopedCleanups.splice(0).forEach((cleanup) => cleanup()); + resetShortcutContextForTests(); + useDAWStore.setState(original); + vi.restoreAllMocks(); + }); + + it.each(TEST_PLATFORMS)( + "keeps FL Studio Backspace snap reachable above Piano Roll deletion on %s", + (platform) => { + scopedCleanups.push(registerScopedActionExecutor( + { kind: "piano_roll", sessionId: "fl-collision" }, + (actionId) => actionId === "midi.deleteSelection" ? "handled" : "unmatched", + ["midi.deleteSelection"], + )); + const backspace = dispatchAction( + "fl_studio", + platform, + { kind: "piano_roll", sessionId: "fl-collision" }, + { key: "Backspace", code: "Backspace", source: "profile-collision-test" }, + ); + expect(getEffectiveActionShortcuts( + getRegisteredActions().find((action) => action.id === "view.toggleSnap")!, + platform, + )).toEqual(["Backspace"]); + expect(resolveRegistryShortcutAction( + { key: "Backspace", code: "Backspace" }, + platform, + )?.action.id).toBe("view.toggleSnap"); + expect(backspace).toEqual({ + handled: true, + actionIds: ["view.toggleSnap"], + prevented: true, + }); + + const deleteKey = dispatchAction( + "fl_studio", + platform, + { kind: "piano_roll", sessionId: "fl-collision" }, + { key: "Delete", code: "Delete", source: "profile-collision-test" }, + ); + expect(deleteKey).toEqual({ + handled: true, + actionIds: ["midi.deleteSelection"], + prevented: true, + }); + + const automationBackspace = dispatchAction( + "fl_studio", + platform, + { kind: "automation" }, + { key: "Backspace", code: "Backspace", source: "profile-collision-test" }, + ); + expect(automationBackspace.actionIds).toEqual(["view.toggleSnap"]); + }, + ); + + it.each([ + ["logic_pro", "macos"], + ["logic_pro", "windows"], + ["garageband", "macos"], + ["garageband", "windows"], + ["ableton_live", "macos"], + ["ableton_live", "windows"], + ] as const)("dispatches %s A to arrangement automation on %s", (profileId, platform) => { + expect(dispatchAction( + profileId, + platform, + { kind: "timeline" }, + { key: "a", code: "KeyA", source: "profile-collision-test" }, + )).toEqual({ + handled: true, + actionIds: ["automation.toggleArrangementView"], + prevented: true, + }); + }); + + it.each(TEST_PLATFORMS)( + "does not let GarageBand's inherited timeline tools steal A/C/B/Y on %s", + (platform) => { + const modifier = platform === "macos" ? { metaKey: true } : { ctrlKey: true }; + expect(dispatchAction( + "garageband", + platform, + { kind: "timeline" }, + { key: "c", code: "KeyC", source: "profile-collision-test" }, + ).actionIds).toEqual(["transport.loop"]); + expect(dispatchAction( + "garageband", + platform, + { kind: "timeline" }, + { key: "b", code: "KeyB", source: "profile-collision-test" }, + ).actionIds).toEqual([]); + expect(dispatchAction( + "garageband", + platform, + { kind: "timeline" }, + { key: "y", code: "KeyY", source: "profile-collision-test" }, + ).actionIds).toEqual([]); + + // The explicit unbinds do not affect GarageBand's real split command. + expect(dispatchAction( + "garageband", + platform, + { kind: "timeline" }, + { + key: "t", + code: "KeyT", + ...modifier, + source: "profile-collision-test", + }, + ).actionIds).toEqual(["edit.splitAtCursor"]); + }, + ); + + it.each([ + ["macos", { ctrlKey: true, metaKey: true }, "Control+Command"], + ["windows", { ctrlKey: true, metaKey: true }, "Control+Meta"], + ] as const)("dispatches Logic's exact automation modes on %s", (platform, modifiers, _bindingStyle) => { + const selected = [ + ["o", "automation.selectedTracks.toggleOffRead"], + ["a", "automation.selectedTracks.toggleLatchRead"], + ] as const; + for (const [key, actionId] of selected) { + expect(dispatchAction( + "logic_pro", + platform, + { kind: "automation" }, + { + key, + code: `Key${key.toUpperCase()}`, + ...modifiers, + source: "profile-collision-test", + }, + ).actionIds).toEqual([actionId]); + } + + const allTracks = [ + ["o", "automation.allTracks.mode.off"], + ["r", "automation.allTracks.mode.read"], + ["t", "automation.allTracks.mode.touch"], + ["l", "automation.allTracks.mode.latch"], + ] as const; + for (const [key, actionId] of allTracks) { + expect(dispatchAction( + "logic_pro", + platform, + { kind: "automation" }, + { + key, + code: `Key${key.toUpperCase()}`, + ...modifiers, + shiftKey: true, + source: "profile-collision-test", + }, + ).actionIds).toEqual([actionId]); + } + }); + + it.each(TEST_PLATFORMS)( + "dispatches Cakewalk all-track automation safety commands on %s", + (platform) => { + expect(dispatchAction( + "cakewalk_sonar", + platform, + { kind: "automation" }, + { key: "F12", code: "F12", source: "profile-collision-test" }, + ).actionIds).toEqual(["automation.allTracks.writeOff"]); + expect(dispatchAction( + "cakewalk_sonar", + platform, + { kind: "automation" }, + { + key: "F12", + code: "F12", + ctrlKey: platform === "windows", + metaKey: platform === "macos", + source: "profile-collision-test", + }, + ).actionIds).toEqual(["automation.allTracks.toggleRead"]); + }, + ); + + it("has no undocumented inherited editor action shadowing an explicit global profile chord", () => { + const semanticallyEquivalent = new Set([ + "edit.copy->midi.copySelection", + "edit.cut->midi.cutSelection", + "edit.paste->midi.pasteSelection", + "edit.delete->midi.deleteSelection", + ]); + const collisions: string[] = []; + + for (const profile of KEYBOARD_SHORTCUT_PROFILES) { + for (const platform of TEST_PLATFORMS) { + for (const globalAction of getRegisteredActions()) { + const globalBindings = getProfileActionBindings(profile.id, globalAction.id, platform); + if (!globalBindings?.length) continue; + if (!getActionShortcutScopes(globalAction, profile.id).includes("global")) continue; + + for (const editorAction of getRegisteredActions()) { + if (getProfileActionBindings(profile.id, editorAction.id, platform) !== undefined) continue; + const editorScopes = getActionShortcutScopes(editorAction, profile.id) + .filter((scope) => scope !== "global"); + if (editorScopes.length === 0) continue; + if (semanticallyEquivalent.has(`${globalAction.id}->${editorAction.id}`)) continue; + + const inheritedBindings = [ + editorAction.shortcut, + ...(editorAction.shortcutAliases ?? []), + ].filter((binding): binding is string => Boolean(binding) && !binding!.includes("(")); + for (const globalBinding of globalBindings) { + const globalSignature = shortcutBindingEventSignature(globalBinding, platform); + if (!globalSignature) continue; + for (const inheritedBinding of inheritedBindings) { + if (shortcutBindingEventSignature(inheritedBinding, platform) !== globalSignature) continue; + collisions.push( + `${profile.id}/${platform}: ${globalAction.id} (${globalBinding}) ` + + `is shadowed by inherited ${editorAction.id} (${inheritedBinding}) ` + + `in ${editorScopes.join(",")}`, + ); + } + } + } + } + } + } + + expect(collisions).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/profiledRangeInput.test.tsx b/frontend/src/__tests__/profiledRangeInput.test.tsx new file mode 100644 index 0000000..4db62b4 --- /dev/null +++ b/frontend/src/__tests__/profiledRangeInput.test.tsx @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ProfiledRangeInput } from "../components/ui/ProfiledRangeInput"; +import { useDAWStore } from "../store/useDAWStore"; +import { getParameterWheelValue, resolveProfiledParameterWheel } from "../utils/parameterWheel"; +import profiledRangeSource from "../components/ui/ProfiledRangeInput/ProfiledRangeInput.tsx?raw"; +import builtInPluginSource from "../components/BuiltInPluginPanel.tsx?raw"; +import channelEqSource from "../components/ChannelStripEQModal.tsx?raw"; +import fxChainSource from "../components/FXChainPanel.tsx?raw"; +import pitchCorrectorSource from "../components/PitchCorrectorPanel.tsx?raw"; +import correctPitchSource from "../components/CorrectPitchModal.tsx?raw"; +import pianoRollSource from "../components/PianoRoll.tsx?raw"; +import controllerLaneSource from "../components/PianoRollControllerLaneSection.tsx?raw"; +import preferencesSource from "../components/PreferencesModal.tsx?raw"; + +const allComponentSources = import.meta.glob("../components/**/*.tsx", { + eager: true, + query: "?raw", + import: "default", +}) as Record; + +const originalMouseProfile = useDAWStore.getState().mouseBehaviorProfileId; + +afterEach(() => { + useDAWStore.setState({ mouseBehaviorProfileId: originalMouseProfile }); +}); + +describe("ProfiledRangeInput", () => { + it("stays visually bare and preserves caller styling and accessibility attributes", () => { + const html = renderToStaticMarkup( + undefined} + className="existing-range-style" + style={{ writingMode: "horizontal-tb" }} + aria-label="Formant shift" + title="Existing title" + />, + ); + + expect(html).toContain('type="range"'); + expect(html).toContain('class="existing-range-style"'); + expect(html).toContain('aria-label="Formant shift"'); + expect(html).toContain('title="Existing title"'); + expect(html).not.toContain("profiled-range-wrapper"); + }); + + it("resolves Cubase plain, fine, and unsupported modifier gestures exactly", () => { + useDAWStore.setState({ mouseBehaviorProfileId: "cubase" }); + + const plain = resolveProfiledParameterWheel({ deltaY: -100 }, "control"); + const fine = resolveProfiledParameterWheel({ deltaY: -100, shiftKey: true }, "control"); + const unsupported = resolveProfiledParameterWheel({ deltaY: -100, altKey: true }, "control"); + + expect(plain).toMatchObject({ + ruleId: "cubase.parameter-adjust", + operation: "adjust", + precision: "normal", + preventDefault: true, + stopPropagation: true, + }); + expect(fine).toMatchObject({ + ruleId: "cubase.parameter-fine-adjust", + operation: "adjust", + precision: "fine", + preventDefault: true, + stopPropagation: true, + }); + expect(unsupported).toMatchObject({ + ruleId: "cubase.parameter-unsupported-wheel", + operation: "suppress", + preventDefault: true, + stopPropagation: true, + }); + expect(getParameterWheelValue(plain, { + min: 0, + max: 1, + value: 0.5, + step: 0.1, + })).toBe(0.6); + expect(getParameterWheelValue(fine, { + min: 0, + max: 1, + value: 0.5, + step: 0.1, + })).toBe(0.51); + expect(getParameterWheelValue(unsupported, { + min: 0, + max: 1, + value: 0.5, + step: 0.1, + })).toBe(0.5); + }); + + it("owns resolved event cancellation and captures all edit completion paths", () => { + expect(profiledRangeSource).toContain("resolveProfiledParameterWheel(event.nativeEvent, wheelSubtarget)"); + expect(profiledRangeSource).toContain("if (gesture.preventDefault) event.preventDefault()"); + expect(profiledRangeSource).toContain("if (gesture.stopPropagation) event.stopPropagation()"); + expect(profiledRangeSource).toContain('if (gesture.operation !== "adjust") {'); + expect(profiledRangeSource).toContain("getParameterWheelValue({ ...gesture, amount: emittedAmount }"); + expect(profiledRangeSource).toContain("beginEditTransaction(wheelEditRef.current, onBeginEdit, onCommitEdit)"); + expect(profiledRangeSource).toContain("createWheelDeltaAccumulator({"); + expect(profiledRangeSource).toContain("wheelAccumulatorRef.current?.consume(targetKey, gesture.amount)"); + expect(profiledRangeSource).toContain("accumulator.dispose()"); + expect(profiledRangeSource).toContain("onPointerCancel={handlePointerCancel}"); + expect(profiledRangeSource).toContain("onLostPointerCapture={handleLostPointerCapture}"); + expect(profiledRangeSource).toContain("onBlur={handleBlur}"); + expect(profiledRangeSource).toContain("commitEditTransaction(keyEdit)"); + }); +}); + +describe("profiled native range coverage", () => { + const coveredSources = [ + ["BuiltInPluginPanel", builtInPluginSource, 1], + ["ChannelStripEQModal", channelEqSource, 3], + ["FXChainPanel", fxChainSource, 2], + ["PitchCorrectorPanel", pitchCorrectorSource, 2], + ["CorrectPitchModal", correctPitchSource, 2], + ["PianoRoll", pianoRollSource, 2], + ["PianoRollControllerLaneSection", controllerLaneSource, 1], + ["PreferencesModal", preferencesSource, 1], + ] as const; + + it.each(coveredSources)("routes every %s range through ProfiledRangeInput", (_name, source, expectedCount) => { + expect(source.match(/ { + const filesWithNativeRanges = Object.entries(allComponentSources) + .filter(([, source]) => /type\s*=\s*["']range["']/.test(source)) + .map(([path]) => path) + .sort(); + const allowedNativeRangeImplementations = new Set([ + "../components/NAMRackKnob.tsx", + "../components/NAMRackPanel.tsx", + "../components/ui/ProfiledRangeInput/ProfiledRangeInput.tsx", + "../components/ui/Slider/Slider.tsx", + ]); + + expect(filesWithNativeRanges.filter((path) => !allowedNativeRangeImplementations.has(path))).toEqual([]); + expect(filesWithNativeRanges).toContain("../components/ui/ProfiledRangeInput/ProfiledRangeInput.tsx"); + expect(filesWithNativeRanges).toContain("../components/ui/Slider/Slider.tsx"); + }); + + it("keeps plugin automation touch paired across pointer, wheel, key and cleanup commits", () => { + expect(fxChainSource).toContain("onBeginEdit={() => {"); + expect(fxChainSource).toContain("beginAutomationParamTouch("); + expect(fxChainSource).toContain("onCommitEdit={() => {"); + expect(fxChainSource).toContain("endAutomationParamTouch("); + }); +}); diff --git a/frontend/src/__tests__/profiledTimelineClipDrag.test.ts b/frontend/src/__tests__/profiledTimelineClipDrag.test.ts new file mode 100644 index 0000000..bd28130 --- /dev/null +++ b/frontend/src/__tests__/profiledTimelineClipDrag.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; +import { getMouseBehaviorProfile } from "../utils/mouseBehaviorProfiles"; +import { + resolveMouseModifierAction, + type MouseModifierPlatform, +} from "../utils/mouseModifierResolver"; +import { + isTimelineCopyDropNoop, + resolveProfiledTimelineClipDrag, +} from "../utils/profiledTimelineClipDrag"; + +const originalState = useDAWStore.getState(); + +function audioClip(id: string): AudioClip { + return { + id, + filePath: `C:/audio/${id}.wav`, + name: id, + startTime: 3.25, + duration: 2, + offset: 0, + color: "#224466", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }; +} + +function midiClip(id: string): MIDIClip { + return { + id, + name: id, + startTime: 5.5, + duration: 1, + sourceLength: 1, + loopLength: 1, + events: [ + { timestamp: 0, type: "noteOn", note: 60, velocity: 100 }, + { timestamp: 0.5, type: "noteOff", note: 60, velocity: 0 }, + ], + ccEvents: [], + color: "#664422", + }; +} + +function resolveClipDrag( + profileId: "studio_one" | "mixcraft", + platform: MouseModifierPlatform, + event: { altKey?: boolean; shiftKey?: boolean }, +) { + const profile = getMouseBehaviorProfile( + profileId, + platform === "macos" ? "macos" : "windows", + ); + return resolveMouseModifierAction(event, "clip_drag", { + platform, + profile: profile.modifiers, + }); +} + +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState({ + tracks: [], + selectedTrackId: null, + selectedTrackIds: [], + selectedClipId: null, + selectedClipIds: [], + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + canUndo: false, + canRedo: false, + syncClipsWithBackend: vi.fn(async () => undefined), + syncMIDITrackToBackend: vi.fn(async () => undefined), + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("profiled Timeline clip drag", () => { + it.each(["windows", "macos"] as const)( + "starts Studio One normally on %s and evaluates Shift live after pointer-down", + (platform) => { + expect(resolveClipDrag("studio_one", platform, {})).toBe("move"); + // Shift is deliberately not a gesture starter in Studio One. + expect(resolveClipDrag("studio_one", platform, { shiftKey: true })).toBe("none"); + + const base = { + profileId: "studio_one", + copyOnDrag: false, + snapBypassRequested: false, + preserveTimeRequested: false, + axisLockRequested: false, + axisLock: null, + rawDeltaX: 37, + rawDeltaY: 14, + } as const; + + expect(resolveProfiledTimelineClipDrag({ + ...base, + liveModifiers: {}, + }).effectiveSnapBypass).toBe(false); + expect(resolveProfiledTimelineClipDrag({ + ...base, + liveModifiers: { shiftKey: true }, + }).effectiveSnapBypass).toBe(true); + expect(resolveProfiledTimelineClipDrag({ + ...base, + liveModifiers: { shiftKey: false }, + }).effectiveSnapBypass).toBe(false); + }, + ); + + it.each(["windows", "macos"] as const)( + "resolves Mixcraft Alt/Option+Shift as copy-with-preserved-time on %s", + (platform) => { + expect(resolveClipDrag("mixcraft", platform, { altKey: true })).toBe("copy"); + expect(resolveClipDrag("mixcraft", platform, { + altKey: true, + shiftKey: true, + })).toBe("copy_preserve_time"); + + const base = { + profileId: "mixcraft", + copyOnDrag: true, + snapBypassRequested: false, + preserveTimeRequested: true, + axisLockRequested: false, + axisLock: null, + rawDeltaX: 85, + rawDeltaY: 72, + } as const; + expect(resolveProfiledTimelineClipDrag({ + ...base, + liveModifiers: { altKey: true, shiftKey: true }, + })).toMatchObject({ + preserveTime: true, + effectiveSnapBypass: false, + axisLockRequested: true, + axisLock: "y", + deltaX: 0, + deltaY: 72, + }); + + // Modifier changes are honored during the same established copy drag. + expect(resolveProfiledTimelineClipDrag({ + ...base, + liveModifiers: { altKey: true, shiftKey: false }, + })).toMatchObject({ + preserveTime: false, + axisLockRequested: false, + axisLock: null, + deltaX: 85, + deltaY: 72, + }); + expect(resolveProfiledTimelineClipDrag({ + ...base, + preserveTimeRequested: false, + liveModifiers: { altKey: true, shiftKey: true }, + }).preserveTime).toBe(true); + }, + ); + + it("treats a preserved-time copy released on its source track as a true no-op", () => { + expect(isTimelineCopyDropNoop({ + originalStartTime: 3.25, + previewStartTime: 3.25, + pixelsPerSecond: 100, + anchorTrackIndex: 0, + targetTrackIndex: 0, + showGhostTrack: false, + })).toBe(true); + expect(isTimelineCopyDropNoop({ + originalStartTime: 3.25, + previewStartTime: 3.25, + pixelsPerSecond: 100, + anchorTrackIndex: 0, + targetTrackIndex: 1, + showGhostTrack: false, + })).toBe(false); + }); + + it.each(["audio", "midi"] as const)( + "commits one atomic preserved-time %s copy and restores it exactly on undo/redo", + (kind) => { + const source = createDefaultTrack( + "source", + "Source", + "#111111", + kind, + [], + ); + const target = createDefaultTrack( + "target", + "Target", + "#222222", + kind, + [], + ); + const clip = kind === "audio" ? audioClip("source-clip") : midiClip("source-clip"); + if (kind === "audio") source.clips = [clip as AudioClip]; + else source.midiClips = [clip as MIDIClip]; + useDAWStore.setState({ + tracks: [source, target], + selectedTrackId: source.id, + selectedTrackIds: [source.id], + selectedClipId: clip.id, + selectedClipIds: [clip.id], + }); + + const copiedId = useDAWStore.getState().duplicateClipToPosition( + clip.id, + target.id, + clip.startTime, + ); + expect(copiedId).toBeTypeOf("string"); + expect(commandManager.getUndoStack()).toHaveLength(1); + const copied = kind === "audio" + ? useDAWStore.getState().tracks[1].clips[0] + : useDAWStore.getState().tracks[1].midiClips[0]; + expect(copied).toMatchObject({ id: copiedId, startTime: clip.startTime }); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[1].clips).toEqual([]); + expect(useDAWStore.getState().tracks[1].midiClips).toEqual([]); + expect(useDAWStore.getState().selectedClipIds).toEqual([clip.id]); + + useDAWStore.getState().redo(); + const redone = kind === "audio" + ? useDAWStore.getState().tracks[1].clips[0] + : useDAWStore.getState().tracks[1].midiClips[0]; + expect(redone).toMatchObject({ id: copiedId, startTime: clip.startTime }); + }, + ); + + it("cancels a copy without history when a central lock engages before commit", () => { + const source = createDefaultTrack("source", "Source", "#111111", "audio", []); + const target = createDefaultTrack("target", "Target", "#222222", "audio", []); + source.clips = [audioClip("source-clip")]; + useDAWStore.setState({ + tracks: [source, target], + selectedClipId: "source-clip", + selectedClipIds: ["source-clip"], + globalLocked: true, + }); + + expect(useDAWStore.getState().duplicateClipToPosition( + "source-clip", + "target", + 3.25, + )).toBeNull(); + expect(useDAWStore.getState().tracks[1].clips).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/recordingWaveformAndRenderReporting.test.ts b/frontend/src/__tests__/recordingWaveformAndRenderReporting.test.ts new file mode 100644 index 0000000..471a5e5 --- /dev/null +++ b/frontend/src/__tests__/recordingWaveformAndRenderReporting.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import timelineSource from "../components/Timeline.tsx?raw"; +import renderModalSource from "../components/RenderModal.tsx?raw"; +import nativeBridgeSource from "../services/NativeBridge.ts?raw"; +import mainComponentSource from "../../../Source/MainComponent.cpp?raw"; + +function normalizeSourceText(source: string): string { + return source.replace(/\r\n?/g, "\n"); +} + +describe("live recording waveform regression guards", () => { + it("requests only the missing recording peak tail", () => { + const normalizedTimelineSource = normalizeSourceText(timelineSource); + const normalizedNativeBridgeSource = normalizeSourceText(nativeBridgeSource); + expect(normalizedTimelineSource).toContain( + "const startSample = previousPeaks.length * samplesPerPixel", + ); + expect(normalizedTimelineSource).toContain( + "Math.ceil(Math.max(0, recordedSamples - startSample) / samplesPerPixel)", + ); + expect(normalizedTimelineSource).toContain( + "samplesPerPixel,\n missingPixels,\n startSample", + ); + expect(normalizedTimelineSource).toContain("if (inFlight) return;"); + expect(normalizedNativeBridgeSource).toContain("startSample = 0"); + expect(normalizedNativeBridgeSource).toContain( + "numPixels,\n startSample", + ); + }); + + it("sizes and clips the preview from actual returned peaks", () => { + expect(timelineSource).toContain( + "const widthPixels = peaks.length * samplesPerPixel / deviceSR * pixelsPerSecond", + ); + expect(timelineSource).toContain("const firstVisiblePeak = Math.max("); + expect(timelineSource).toContain("const lastVisiblePeak = Math.min("); + }); +}); + +describe("render result reporting regression guards", () => { + it("does not impose the generic 15-second bridge timeout on offline renders", () => { + expect(mainComponentSource).toContain( + "const NO_TIMEOUT_FUNCTIONS = ['scanForPlugins', 'renderProject', 'renderProjectWithDither']", + ); + }); + + it("checks native boolean results and distinguishes later-stage failures", () => { + expect(renderModalSource).toContain("if (!success)"); + expect(renderModalSource).toContain("Audio engine rejected the ${params.source} render"); + expect(renderModalSource).toContain( + "Primary render completed, but the secondary ${secondaryOutputFormat.toUpperCase()} output failed", + ); + expect(renderModalSource).toContain("primary file${renderedFiles.length === 1 ? \" was\" : \"s were\"} rendered successfully"); + }); +}); diff --git a/frontend/src/__tests__/remainingVendorActions.test.ts b/frontend/src/__tests__/remainingVendorActions.test.ts new file mode 100644 index 0000000..eb3eea0 --- /dev/null +++ b/frontend/src/__tests__/remainingVendorActions.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; +import { getProfileActionBindings } from "../utils/shortcutProfiles"; +import { buildTrackFolderGroupPlan } from "../utils/trackFolderGrouping"; +import { dispatchGlobalShortcut } from "../utils/globalShortcutDispatcher"; +import { + activateShortcutContext, + registerShortcutSurface, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; +import { + collectClipIdsInsideTimeSelection, + collectTimelineBoundaryTimes, + resolveAdjacentGridLineTime, + resolveAdjacentTimelineBoundary, +} from "../utils/vendorNavigation"; + +const originalState = useDAWStore.getState(); + +function clip(id: string, startTime: number, duration: number) { + return { id, startTime, duration }; +} + +beforeEach(() => { + commandManager.clear(); + resetShortcutContextForTests(); + useDAWStore.setState({ + tracks: [], + markers: [], + timeSelection: null, + selectedClipId: null, + selectedClipIds: [], + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + transport: { + ...originalState.transport, + tempo: 120, + currentTime: 0, + isPlaying: false, + isPaused: false, + isRecording: false, + }, + timeSignature: { numerator: 4, denominator: 4 }, + gridSize: "1/4", + quantizePresetId: "factory-1/16", + pixelsPerSecond: 100, + canUndo: false, + canRedo: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + resetShortcutContextForTests(); + useDAWStore.setState(originalState); +}); + +describe("vendor navigation primitives", () => { + it("moves strictly between grid lines, handles fractional positions, and clamps at zero", () => { + expect(resolveAdjacentGridLineTime(1, 0.5, "next")).toBe(1.5); + expect(resolveAdjacentGridLineTime(1, 0.5, "previous")).toBe(0.5); + expect(resolveAdjacentGridLineTime(1.1, 0.5, "next")).toBe(1.5); + expect(resolveAdjacentGridLineTime(1.1, 0.5, "previous")).toBe(1); + expect(resolveAdjacentGridLineTime(0, 0.5, "previous")).toBeNull(); + expect(resolveAdjacentGridLineTime(Number.NaN, 0.5, "next")).toBeNull(); + expect(resolveAdjacentGridLineTime(1, 0, "next")).toBeNull(); + }); + + it("collects the exact Audition boundary union and collapses ties", () => { + const boundaries = collectTimelineBoundaryTimes({ + tracks: [{ + clips: [clip("audio", 1, 2)], + midiClips: [clip("midi", 3, 1), clip("invalid", Number.NaN, 1)], + }], + markers: [{ time: -1 }, { time: 1 }, { time: 6 }], + timeSelection: { start: 3, end: 5 }, + }); + expect(boundaries).toEqual([0, 1, 3, 4, 5, 6]); + expect(resolveAdjacentTimelineBoundary(3, boundaries, "previous")).toBe(1); + expect(resolveAdjacentTimelineBoundary(3, boundaries, "next")).toBe(4); + expect(resolveAdjacentTimelineBoundary(0, boundaries, "previous")).toBeNull(); + expect(resolveAdjacentTimelineBoundary(6, boundaries, "next")).toBeNull(); + }); + + it("selects only audio and MIDI clips fully contained by a normalized time selection", () => { + const tracks = [{ + clips: [clip("exact", 1, 2), clip("overlap-left", 0.5, 1), clip("overlap-right", 2.5, 1)], + midiClips: [clip("midi-inside", 1.5, 0.5)], + }]; + expect(collectClipIdsInsideTimeSelection(tracks, { start: 3, end: 1 })).toEqual([ + "exact", + "midi-inside", + ]); + expect(collectClipIdsInsideTimeSelection(tracks, null)).toEqual([]); + }); +}); + +describe("track folder grouping plan", () => { + it("gathers selected subtrees at the first selected position while preserving order", () => { + expect(buildTrackFolderGroupPlan([ + { id: "before" }, + { id: "first" }, + { id: "first-child", parentFolderId: "first" }, + { id: "between" }, + { id: "second" }, + { id: "after" }, + ], ["second", "first"], "group")).toEqual({ + orderedTrackIds: ["before", "group", "first", "first-child", "second", "between", "after"], + selectedRootIds: ["first", "second"], + parentFolderId: undefined, + }); + }); + + it("inherits a common parent and rejects cross-level, ancestor, cyclic, and empty selections", () => { + const nested = [ + { id: "parent" }, + { id: "one", parentFolderId: "parent" }, + { id: "two", parentFolderId: "parent" }, + { id: "root" }, + ]; + expect(buildTrackFolderGroupPlan(nested, ["one", "two"], "group")?.parentFolderId).toBe("parent"); + expect(buildTrackFolderGroupPlan(nested, ["one", "root"], "group")).toBeNull(); + expect(buildTrackFolderGroupPlan(nested, ["parent", "one"], "group")).toBeNull(); + expect(buildTrackFolderGroupPlan([{ id: "a", parentFolderId: "b" }, { id: "b", parentFolderId: "a" }], ["a"], "group")).toBeNull(); + expect(buildTrackFolderGroupPlan(nested, [], "group")).toBeNull(); + expect(buildTrackFolderGroupPlan(nested, ["missing"], "group")).toBeNull(); + }); +}); + +describe("remaining exact vendor actions", () => { + it("registers exact timeline-scoped commands and profile chords on both layouts", () => { + for (const actionId of [ + "navigate.previousGridLine", + "navigate.nextGridLine", + "navigate.previousBoundary", + "navigate.nextBoundary", + "edit.selectClipsInTimeSelection", + ]) { + expect(getRegisteredAction(actionId)?.shortcutScope, actionId).toBe("timeline"); + } + + for (const platform of ["macos", "windows"] as const) { + expect(getProfileActionBindings("ardour", "navigate.previousGridLine", platform)).toEqual(["Left"]); + expect(getProfileActionBindings("ardour", "navigate.nextGridLine", platform)).toEqual(["Right"]); + expect(getProfileActionBindings("ardour", "edit.selectClipsInTimeSelection", platform)).toEqual(["U"]); + expect(getProfileActionBindings("adobe_audition", "navigate.previousBoundary", platform)).toEqual(["Ctrl+Left"]); + expect(getProfileActionBindings("adobe_audition", "navigate.nextBoundary", platform)).toEqual(["Ctrl+Right"]); + expect(getProfileActionBindings("studio_one", "edit.muteSelectedClips", platform)).toEqual(["Shift+M"]); + expect(getProfileActionBindings("studio_one", "edit.unmuteSelectedClips", platform)).toEqual(["Shift+U"]); + expect(getProfileActionBindings("studio_one", "insert.markerNamed", platform)).toEqual([]); + expect(getProfileActionBindings("ableton_live", "track.groupSelectedIntoFolder", platform)).toEqual(["Ctrl+G"]); + expect(getProfileActionBindings("bitwig_studio", "track.groupSelectedIntoFolder", platform)).toEqual(["Ctrl+G"]); + } + }); + + it("seeks one active musical grid division and no-ops before zero", () => { + const seekTo = vi.fn(async (time: number) => { + useDAWStore.setState((state) => ({ transport: { ...state.transport, currentTime: time } })); + }); + useDAWStore.setState((state) => ({ + seekTo, + transport: { ...state.transport, currentTime: 1 }, + })); + + const previous = getRegisteredAction("navigate.previousGridLine")!; + const next = getRegisteredAction("navigate.nextGridLine")!; + expect(previous.canHandleShortcut?.()).toBe(true); + previous.execute(); + expect(seekTo).toHaveBeenLastCalledWith(0.5); + next.execute(); + expect(seekTo).toHaveBeenLastCalledWith(1); + + useDAWStore.setState((state) => ({ transport: { ...state.transport, currentTime: 0 } })); + seekTo.mockClear(); + expect(previous.canHandleShortcut?.()).toBe(false); + previous.execute(); + expect(seekTo).not.toHaveBeenCalled(); + }); + + it("navigates tied marker/clip/selection boundaries and no-ops without a target", () => { + const track = createDefaultTrack("track", "Track", "#123456", "audio", []); + const seekTo = vi.fn(async (time: number) => { + useDAWStore.setState((state) => ({ transport: { ...state.transport, currentTime: time } })); + }); + useDAWStore.setState((state) => ({ + seekTo, + tracks: [{ + ...track, + clips: [{ + id: "clip", + filePath: "C:/audio.wav", + name: "Clip", + startTime: 1, + duration: 2, + offset: 0, + color: "#123456", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }], + }], + markers: [{ id: "marker", time: 1, name: "Marker", color: "#fff" }], + timeSelection: { start: 3, end: 5 }, + transport: { ...state.transport, currentTime: 3 }, + })); + + const previous = getRegisteredAction("navigate.previousBoundary")!; + const next = getRegisteredAction("navigate.nextBoundary")!; + previous.execute(); + expect(seekTo).toHaveBeenLastCalledWith(1); + next.execute(); + expect(seekTo).toHaveBeenLastCalledWith(3); + + useDAWStore.setState((state) => ({ + tracks: [], + markers: [], + timeSelection: null, + transport: { ...state.transport, currentTime: 8 }, + })); + seekTo.mockClear(); + expect(next.canHandleShortcut?.()).toBe(false); + next.execute(); + expect(seekTo).not.toHaveBeenCalled(); + }); + + it("selects contained clips without creating project history and clears track selection", () => { + const audioTrack = createDefaultTrack("audio-track", "Audio", "#123456", "audio", []); + const midiTrack = createDefaultTrack("midi-track", "MIDI", "#654321", "midi", []); + useDAWStore.setState({ + tracks: [ + { + ...audioTrack, + clips: [ + { id: "inside", filePath: "C:/inside.wav", name: "Inside", startTime: 1, duration: 1, offset: 0, color: "#123456", volumeDB: 0, fadeIn: 0, fadeOut: 0 }, + { id: "overlap", filePath: "C:/overlap.wav", name: "Overlap", startTime: 0.5, duration: 1, offset: 0, color: "#123456", volumeDB: 0, fadeIn: 0, fadeOut: 0 }, + ], + }, + { + ...midiTrack, + midiClips: [{ id: "midi", name: "MIDI", startTime: 2, duration: 1, sourceLength: 1, loopLength: 1, events: [], ccEvents: [], color: "#654321" }], + }, + ], + timeSelection: { start: 1, end: 3 }, + selectedTrackId: "audio-track", + selectedTrackIds: ["audio-track"], + lastSelectedTrackId: "audio-track", + }); + + const action = getRegisteredAction("edit.selectClipsInTimeSelection")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + expect(useDAWStore.getState()).toMatchObject({ + selectedClipIds: ["inside", "midi"], + selectedClipId: "midi", + selectedTrackIds: [], + selectedTrackId: null, + lastSelectedTrackId: null, + canUndo: false, + }); + }); + + it("dispatches Ardour grid navigation and Audition boundary navigation in Timeline context", () => { + const unregister = registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + const seekTo = vi.fn(async (time: number) => { + useDAWStore.setState((state) => ({ transport: { ...state.transport, currentTime: time } })); + }); + useDAWStore.setState((state) => ({ + keyboardShortcutProfileId: "ardour", + customShortcuts: {}, + seekTo, + transport: { ...state.transport, currentTime: 1 }, + })); + + expect(dispatchGlobalShortcut({ key: "ArrowLeft", code: "ArrowLeft", source: "browser" }, "windows")).toBe(true); + expect(seekTo).toHaveBeenLastCalledWith(0.5); + expect(dispatchGlobalShortcut({ key: "ArrowRight", code: "ArrowRight", source: "browser" }, "windows")).toBe(true); + expect(seekTo).toHaveBeenLastCalledWith(1); + + const track = createDefaultTrack("track", "Track", "#123456", "audio", []); + useDAWStore.setState((state) => ({ + keyboardShortcutProfileId: "adobe_audition", + tracks: [{ ...track, clips: [{ id: "clip", filePath: "C:/clip.wav", name: "Clip", startTime: 1, duration: 2, offset: 0, color: "#123456", volumeDB: 0, fadeIn: 0, fadeOut: 0 }] }], + markers: [{ id: "marker", time: 1, name: "Marker", color: "#fff" }], + timeSelection: { start: 3, end: 5 }, + transport: { ...state.transport, currentTime: 3 }, + })); + expect(dispatchGlobalShortcut({ key: "ArrowLeft", code: "ArrowLeft", metaKey: true, source: "browser" }, "macos")).toBe(true); + expect(seekTo).toHaveBeenLastCalledWith(1); + expect(dispatchGlobalShortcut({ key: "ArrowRight", code: "ArrowRight", metaKey: true, source: "browser" }, "macos")).toBe(true); + expect(seekTo).toHaveBeenLastCalledWith(3); + expect(dispatchGlobalShortcut({ key: "ArrowRight", code: "ArrowRight", ctrlKey: true, source: "browser" }, "macos")).toBe(false); + unregister(); + }); + + it("dispatches Studio One directional mute/unmute without toggling already-matching clips", () => { + const unregister = registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + const track = createDefaultTrack("track", "Track", "#123456", "audio", []); + useDAWStore.setState({ + keyboardShortcutProfileId: "studio_one", + customShortcuts: {}, + tracks: [{ + ...track, + clips: [{ id: "clip", filePath: "C:/clip.wav", name: "Clip", startTime: 0, duration: 1, offset: 0, color: "#123456", volumeDB: 0, fadeIn: 0, fadeOut: 0, muted: false }], + }], + selectedClipId: "clip", + selectedClipIds: ["clip"], + }); + + expect(dispatchGlobalShortcut({ key: "M", code: "KeyM", shiftKey: true, source: "browser" }, "windows")).toBe(true); + expect(useDAWStore.getState().tracks[0].clips[0].muted).toBe(true); + expect(dispatchGlobalShortcut({ key: "M", code: "KeyM", shiftKey: true, source: "browser" }, "windows")).toBe(false); + expect(dispatchGlobalShortcut({ key: "U", code: "KeyU", shiftKey: true, source: "browser" }, "windows")).toBe(true); + expect(useDAWStore.getState().tracks[0].clips[0].muted).toBe(false); + unregister(); + }); + + it("groups same-level selected track subtrees atomically with stable native undo/redo", async () => { + const addTrack = vi.spyOn(nativeBridge, "addTrack").mockImplementation(async (id) => id || "generated"); + const removeTrack = vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + const reorderTrack = vi.spyOn(nativeBridge, "reorderTrack").mockResolvedValue(true); + const parent = { ...createDefaultTrack("parent", "Parent", "#111111", "audio", []), isFolder: true }; + const one = { ...createDefaultTrack("one", "One", "#222222", "audio", []), parentFolderId: "parent" }; + const child = { ...createDefaultTrack("child", "Child", "#333333", "audio", []), parentFolderId: "one" }; + const between = { ...createDefaultTrack("between", "Between", "#444444", "audio", []), parentFolderId: "parent" }; + const two = { ...createDefaultTrack("two", "Two", "#555555", "audio", []), parentFolderId: "parent" }; + const after = createDefaultTrack("after", "After", "#666666", "audio", []); + useDAWStore.setState({ + tracks: [parent, one, child, between, two, after], + selectedTrackId: "two", + selectedTrackIds: ["two", "one"], + lastSelectedTrackId: "two", + }); + + expect(useDAWStore.getState().canGroupSelectedTracksIntoFolder()).toBe(true); + expect(useDAWStore.getState().groupSelectedTracksIntoFolder()).toBe(true); + const grouped = useDAWStore.getState().tracks; + const folder = grouped.find((track) => !["parent", "one", "child", "between", "two", "after"].includes(track.id))!; + expect(folder).toMatchObject({ name: "Group 1", isFolder: true, parentFolderId: "parent" }); + expect(grouped.map((track) => track.id)).toEqual(["parent", folder.id, "one", "child", "two", "between", "after"]); + expect(grouped.find((track) => track.id === "one")?.parentFolderId).toBe(folder.id); + expect(grouped.find((track) => track.id === "two")?.parentFolderId).toBe(folder.id); + expect(grouped.find((track) => track.id === "child")?.parentFolderId).toBe("one"); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["two", "one"]); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["parent", "one", "child", "between", "two", "after"]); + expect(useDAWStore.getState().tracks.find((track) => track.id === "one")?.parentFolderId).toBe("parent"); + expect(useDAWStore.getState().canUndo).toBe(false); + expect(useDAWStore.getState().canRedo).toBe(true); + + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["parent", folder.id, "one", "child", "two", "between", "after"]); + expect(useDAWStore.getState().tracks.find((track) => track.id === "one")?.parentFolderId).toBe(folder.id); + await vi.waitFor(() => { + expect(addTrack.mock.calls.filter(([id]) => id === folder.id)).toHaveLength(2); + expect(removeTrack).toHaveBeenCalledExactlyOnceWith(folder.id); + expect(reorderTrack).toHaveBeenCalledWith(folder.id, 1); + }); + }); + + it("rejects unsafe hierarchy grouping without history or native mutation", () => { + const addTrack = vi.spyOn(nativeBridge, "addTrack").mockImplementation(async (id) => id || "generated"); + const parent = { ...createDefaultTrack("parent", "Parent", "#111111", "audio", []), isFolder: true }; + const child = { ...createDefaultTrack("child", "Child", "#222222", "audio", []), parentFolderId: "parent" }; + const root = createDefaultTrack("root", "Root", "#333333", "audio", []); + useDAWStore.setState({ + tracks: [parent, child, root], + selectedTrackId: "child", + selectedTrackIds: ["child", "root"], + lastSelectedTrackId: "child", + }); + expect(useDAWStore.getState().canGroupSelectedTracksIntoFolder()).toBe(false); + expect(useDAWStore.getState().groupSelectedTracksIntoFolder()).toBe(false); + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["parent", "child", "root"]); + expect(useDAWStore.getState().canUndo).toBe(false); + expect(addTrack).not.toHaveBeenCalled(); + }); + + it.each([ + { profileId: "ableton_live" as const, platform: "windows" as const, event: { key: "g", code: "KeyG", ctrlKey: true } }, + { profileId: "bitwig_studio" as const, platform: "macos" as const, event: { key: "g", code: "KeyG", metaKey: true } }, + ])("dispatches $profileId Group Tracks through Timeline on $platform", ({ profileId, platform, event }) => { + const unregister = registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + const first = createDefaultTrack("first", "First", "#111111", "audio", []); + const second = createDefaultTrack("second", "Second", "#222222", "audio", []); + useDAWStore.setState({ + keyboardShortcutProfileId: profileId, + customShortcuts: {}, + tracks: [first, second], + selectedTrackId: "second", + selectedTrackIds: ["first", "second"], + lastSelectedTrackId: "second", + }); + + expect(dispatchGlobalShortcut({ ...event, source: "browser" }, platform)).toBe(true); + expect(useDAWStore.getState().tracks).toHaveLength(3); + expect(useDAWStore.getState().tracks[0]).toMatchObject({ isFolder: true, name: "Group 1" }); + expect(useDAWStore.getState().tracks.slice(1).every((track) => track.parentFolderId === useDAWStore.getState().tracks[0].id)).toBe(true); + unregister(); + }); +}); diff --git a/frontend/src/__tests__/renderInPlaceAlignment.test.ts b/frontend/src/__tests__/renderInPlaceAlignment.test.ts index 7824a51..8a8ca24 100644 --- a/frontend/src/__tests__/renderInPlaceAlignment.test.ts +++ b/frontend/src/__tests__/renderInPlaceAlignment.test.ts @@ -65,7 +65,7 @@ describe("render in place alignment", () => { vi.spyOn(nativeBridge, "addTrack").mockResolvedValue("rendered-track"); vi.spyOn(nativeBridge, "reorderTrack").mockResolvedValue(true); vi.spyOn(nativeBridge, "addPlaybackClip").mockResolvedValue(true); - vi.spyOn(nativeBridge, "removePlaybackClip").mockResolvedValue(true); + vi.spyOn(nativeBridge, "removePlaybackClipById").mockResolvedValue(true); vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); vi.spyOn(nativeBridge, "refreshWaveformPeaks").mockResolvedValue(true); }); diff --git a/frontend/src/__tests__/reverseClipActionSemantics.test.ts b/frontend/src/__tests__/reverseClipActionSemantics.test.ts new file mode 100644 index 0000000..f71d733 --- /dev/null +++ b/frontend/src/__tests__/reverseClipActionSemantics.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { + executeAvailableRegisteredAction, + getRegisteredAction, +} from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function audioClip(overrides: Partial = {}): AudioClip { + return { + id: "audio", + filePath: "C:/source.wav", + name: "Audio", + startTime: 0, + duration: 4, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + ...overrides, + }; +} + +function midiClip(): MIDIClip { + return { + id: "midi", + name: "MIDI", + startTime: 0, + duration: 4, + offset: 0, + sourceStart: 0, + sourceLength: 4, + loopEnabled: false, + loopOffset: 0, + loopLength: 4, + events: [], + ccEvents: [], + color: "#f72585", + }; +} + +beforeEach(() => { + commandManager.clear(); + const audio = createDefaultTrack("audio-track", "Audio", "#38bdf8", "audio"); + const midi = createDefaultTrack("midi-track", "MIDI", "#f72585", "midi"); + audio.clips = [audioClip()]; + midi.midiClips = [midiClip()]; + useDAWStore.setState({ + tracks: [audio, midi], + selectedClipId: "audio", + selectedClipIds: ["audio"], + globalLocked: false, + lockSettings: { ...originalState.lockSettings, items: false }, + syncClipsWithBackend: vi.fn().mockResolvedValue(undefined), + canUndo: false, + canRedo: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("reverse selected audio clip", () => { + it("syncs execute/undo/redo to playback as one stable transaction", async () => { + vi.spyOn(nativeBridge, "reverseAudioFile").mockResolvedValue("C:/source_reversed.wav"); + const sync = useDAWStore.getState().syncClipsWithBackend as ReturnType; + const action = getRegisteredAction("edit.reverseClip")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + + await vi.waitFor(() => { + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ + filePath: "C:/source_reversed.wav", + reversed: true, + }); + }); + expect(sync).toHaveBeenCalledTimes(1); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ + filePath: "C:/source.wav", + reversed: false, + }); + expect(sync).toHaveBeenCalledTimes(2); + + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ + filePath: "C:/source_reversed.wav", + reversed: true, + }); + expect(sync).toHaveBeenCalledTimes(3); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("claims the focused chord but does not execute for MIDI, mixed, locked, or missing-file targets", () => { + const reverse = vi.spyOn(nativeBridge, "reverseAudioFile"); + const action = getRegisteredAction("edit.reverseClip")!; + const cases = [ + { selectedClipId: "midi", selectedClipIds: ["midi"] }, + { selectedClipId: "audio", selectedClipIds: ["audio", "midi"] }, + ]; + for (const selection of cases) { + useDAWStore.setState(selection); + expect(action.canHandleShortcut?.()).toBe(false); + expect(executeAvailableRegisteredAction("edit.reverseClip")).toBe("claimed_noop"); + } + + useDAWStore.setState((state) => ({ + selectedClipId: "audio", + selectedClipIds: ["audio"], + tracks: state.tracks.map((track) => track.id === "audio-track" + ? { ...track, clips: track.clips.map((clip) => ({ ...clip, locked: true })) } + : track), + })); + expect(action.canHandleShortcut?.()).toBe(false); + expect(executeAvailableRegisteredAction("edit.reverseClip")).toBe("claimed_noop"); + + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id === "audio-track" + ? { ...track, clips: track.clips.map((clip) => ({ ...clip, locked: false, filePath: "" })) } + : track), + })); + expect(action.canHandleShortcut?.()).toBe(false); + expect(executeAvailableRegisteredAction("edit.reverseClip")).toBe("claimed_noop"); + expect(reverse).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("rejects global/item locks and stale async results without history or backend sync", async () => { + let resolveReverse!: (path: string) => void; + vi.spyOn(nativeBridge, "reverseAudioFile").mockImplementation(() => new Promise((resolve) => { + resolveReverse = resolve; + })); + const sync = useDAWStore.getState().syncClipsWithBackend as ReturnType; + + const pending = useDAWStore.getState().reverseClip("audio"); + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id === "audio-track" + ? { ...track, clips: track.clips.map((clip) => ({ ...clip, filePath: "C:/new-source.wav" })) } + : track), + })); + resolveReverse("C:/stale-reversed.wav"); + await pending; + expect(useDAWStore.getState().tracks[0].clips[0].filePath).toBe("C:/new-source.wav"); + expect(sync).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.setState((state) => ({ globalLocked: true, lockSettings: { ...state.lockSettings, items: false } })); + await useDAWStore.getState().reverseClip("audio"); + useDAWStore.setState((state) => ({ globalLocked: false, lockSettings: { ...state.lockSettings, items: true } })); + await useDAWStore.getState().reverseClip("audio"); + expect(nativeBridge.reverseAudioFile).toHaveBeenCalledTimes(1); + }); + + it("rejects frozen tracks both before native work and while a reverse request is pending", async () => { + const action = getRegisteredAction("edit.reverseClip")!; + const reverse = vi.spyOn(nativeBridge, "reverseAudioFile"); + const sync = useDAWStore.getState().syncClipsWithBackend as ReturnType; + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id === "audio-track" + ? { ...track, frozen: true } + : track), + })); + + expect(action.canHandleShortcut?.()).toBe(false); + await useDAWStore.getState().reverseClip("audio"); + expect(reverse).not.toHaveBeenCalled(); + + let resolveReverse!: (path: string) => void; + reverse.mockImplementation(() => new Promise((resolve) => { + resolveReverse = resolve; + })); + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id === "audio-track" + ? { ...track, frozen: false } + : track), + })); + const pending = useDAWStore.getState().reverseClip("audio"); + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id === "audio-track" + ? { ...track, frozen: true } + : track), + })); + resolveReverse("C:/frozen-result.wav"); + await pending; + + expect(useDAWStore.getState().tracks[0].clips[0].filePath).toBe("C:/source.wav"); + expect(sync).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/shortcutAssignmentConflicts.test.ts b/frontend/src/__tests__/shortcutAssignmentConflicts.test.ts new file mode 100644 index 0000000..136d3f4 --- /dev/null +++ b/frontend/src/__tests__/shortcutAssignmentConflicts.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { useDAWStore } from "../store/useDAWStore"; +import { findShortcutAssignmentConflicts } from "../utils/shortcutAssignmentConflicts"; + +describe("shortcut assignment conflicts", () => { + const original = { + keyboardShortcutProfileId: useDAWStore.getState().keyboardShortcutProfileId, + customShortcuts: useDAWStore.getState().customShortcuts, + }; + + afterEach(() => useDAWStore.setState(original)); + + it("finds collisions in the same active profile and scope", () => { + useDAWStore.setState({ keyboardShortcutProfileId: "openstudio", customShortcuts: {} }); + const conflicts = findShortcutAssignmentConflicts("tools.selectTool", "B"); + expect(conflicts.some((conflict) => conflict.actionId === "tools.splitTool")).toBe(true); + }); + + it("allows the same key in independent editor scopes", () => { + const conflicts = findShortcutAssignmentConflicts("pitch.tool.select", "V"); + expect(conflicts.some((conflict) => conflict.actionId === "tools.selectTool")).toBe(false); + }); + + it("includes active-profile scope additions in conflict reporting", () => { + useDAWStore.setState({ keyboardShortcutProfileId: "garageband", customShortcuts: {} }); + expect(findShortcutAssignmentConflicts("tools.selectTool", "M")) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ + actionId: "track.toggleSelectedMute", + sharedScopes: expect.arrayContaining(["timeline"]), + }), + ])); + + useDAWStore.setState({ keyboardShortcutProfileId: "openstudio", customShortcuts: {} }); + expect(findShortcutAssignmentConflicts("tools.selectTool", "M") + .some((conflict) => conflict.actionId === "track.toggleSelectedMute")).toBe(false); + }); + + it("recognizes a label binding colliding with its common physical key", () => { + useDAWStore.setState({ customShortcuts: { "tools.splitTool": "Code:KeyB" } }); + expect(findShortcutAssignmentConflicts("tools.selectTool", "B")) + .toEqual(expect.arrayContaining([expect.objectContaining({ actionId: "tools.splitTool" })])); + }); + + it("does not flag mutually-exclusive Piano Roll tool and step-input conditions", () => { + expect(findShortcutAssignmentConflicts("midi.tool.draw", "C") + .some((conflict) => conflict.actionId === "midi.stepInputC")).toBe(false); + }); + + it("checks explicit platform overrides using that platform's physical modifier map", () => { + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { + "tools.splitTool": { + macos: ["Command+Code:KeyB"], + windows: ["Control+Code:KeyQ"], + }, + }, + }); + + expect(findShortcutAssignmentConflicts( + "tools.selectTool", + "Command+Code:KeyB", + "macos", + )).toEqual(expect.arrayContaining([ + expect.objectContaining({ actionId: "tools.splitTool", platforms: ["macos"] }), + ])); + expect(findShortcutAssignmentConflicts( + "tools.selectTool", + "Command+Code:KeyB", + "windows", + )).toEqual([]); + }); + + it("checks a common binding on every platform and deduplicates action conflicts", () => { + useDAWStore.setState({ keyboardShortcutProfileId: "openstudio", customShortcuts: {} }); + const conflicts = findShortcutAssignmentConflicts("tools.selectTool", "Code:KeyB", "common"); + const splitConflicts = conflicts.filter((conflict) => conflict.actionId === "tools.splitTool"); + expect(splitConflicts).toHaveLength(1); + expect(splitConflicts[0].platforms).toEqual(["macos", "windows", "linux", "other"]); + }); + + it("does not validate a common key where the target already has a platform override", () => { + useDAWStore.setState({ + keyboardShortcutProfileId: "openstudio", + customShortcuts: { + "tools.selectTool": { windows: ["F8"] }, + }, + }); + const conflict = findShortcutAssignmentConflicts("tools.selectTool", "B", "common") + .find((candidate) => candidate.actionId === "tools.splitTool"); + expect(conflict?.platforms).toEqual(["macos", "linux"]); + }); +}); diff --git a/frontend/src/__tests__/shortcutContextRouting.test.ts b/frontend/src/__tests__/shortcutContextRouting.test.ts new file mode 100644 index 0000000..cdb951e --- /dev/null +++ b/frontend/src/__tests__/shortcutContextRouting.test.ts @@ -0,0 +1,516 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; +import { + dispatchGlobalShortcut, + matchesActionShortcut, +} from "../utils/globalShortcutDispatcher"; +import { + activateShortcutContext, + dispatchActiveShortcut, + getActiveShortcutContext, + isEditableShortcutTarget, + isNonTextControlShortcutTarget, + registerShortcutSurface, + resetShortcutContextForTests, + shortcutExactlyMatches, + shouldPreserveEditableShortcut, + shouldPreserveNonTextControlShortcut, + toPressedShortcut, +} from "../utils/shortcutContext"; +import { canonicalizeShortcutEvent, getShortcutPlatform } from "../utils/platform"; + +function hostPrimaryModifier(): { ctrlKey: true } | { metaKey: true } { + return getShortcutPlatform() === "macos" ? { metaKey: true } : { ctrlKey: true }; +} + +function hostLegacySecondaryModifier(): { ctrlKey: true } | { altKey: true } { + return getShortcutPlatform() === "macos" ? { ctrlKey: true } : { altKey: true }; +} + +const originalStoreActions = { + undo: useDAWStore.getState().undo, + saveProject: useDAWStore.getState().saveProject, + selectAllTracks: useDAWStore.getState().selectAllTracks, + toggleMixer: useDAWStore.getState().toggleMixer, + toggleLoop: useDAWStore.getState().toggleLoop, + nudgeClips: useDAWStore.getState().nudgeClips, + copySelectedClips: useDAWStore.getState().copySelectedClips, + openProjectSettings: useDAWStore.getState().openProjectSettings, +}; +const originalSelectionState = { + showPitchEditor: useDAWStore.getState().showPitchEditor, + showPianoRoll: useDAWStore.getState().showPianoRoll, + selectedClipId: useDAWStore.getState().selectedClipId, + selectedClipIds: useDAWStore.getState().selectedClipIds, + selectedNoteIds: useDAWStore.getState().selectedNoteIds, + tracks: useDAWStore.getState().tracks, + globalLocked: useDAWStore.getState().globalLocked, + lockSettings: useDAWStore.getState().lockSettings, +}; + +afterEach(() => { + resetShortcutContextForTests(); + useDAWStore.setState({ + ...originalStoreActions, + ...originalSelectionState, + customShortcuts: {}, + }); +}); + +describe("shortcut edit-context routing", () => { + it("routes only to the active surface and restores its declared fallback", () => { + const timelineHandler = vi.fn(() => "handled" as const); + const pitchHandler = vi.fn(() => "claimed_noop" as const); + const unregisterTimeline = registerShortcutSurface( + { kind: "timeline" }, + timelineHandler, + ); + const unregisterPitch = registerShortcutSurface( + { kind: "pitch_editor" }, + pitchHandler, + { kind: "timeline" }, + ); + + activateShortcutContext({ kind: "timeline" }); + expect(dispatchActiveShortcut({ key: "c", ctrlKey: true })).toBe("handled"); + expect(timelineHandler).toHaveBeenCalledTimes(1); + expect(pitchHandler).not.toHaveBeenCalled(); + + activateShortcutContext({ kind: "pitch_editor" }); + expect(dispatchActiveShortcut({ key: "z", ctrlKey: true })).toBe("claimed_noop"); + expect(pitchHandler).toHaveBeenCalledTimes(1); + + unregisterPitch(); + expect(getActiveShortcutContext()).toEqual({ kind: "timeline" }); + unregisterTimeline(); + }); + + it("isolates handlers for two piano-roll sessions", () => { + const firstSession = vi.fn(() => "handled" as const); + const secondSession = vi.fn(() => "handled" as const); + registerShortcutSurface( + { kind: "piano_roll", sessionId: "detached-one" }, + firstSession, + ); + registerShortcutSurface( + { kind: "piano_roll", sessionId: "detached-two" }, + secondSession, + ); + + activateShortcutContext({ kind: "piano_roll", sessionId: "detached-two" }); + expect(dispatchActiveShortcut({ key: "q" })).toBe("handled"); + expect(firstSession).not.toHaveBeenCalled(); + expect(secondSession).toHaveBeenCalledTimes(1); + }); + + it("restores the previous handler when a same-context owner unmounts", () => { + const baseHandler = vi.fn(() => "handled" as const); + const temporaryHandler = vi.fn(() => "claimed_noop" as const); + const unregisterBase = registerShortcutSurface( + { kind: "timeline" }, + baseHandler, + ); + const unregisterTemporary = registerShortcutSurface( + { kind: "timeline" }, + temporaryHandler, + ); + activateShortcutContext({ kind: "timeline" }); + + expect(dispatchActiveShortcut({ key: "s", repeat: true })).toBe("claimed_noop"); + expect(temporaryHandler).toHaveBeenCalledWith({ key: "s", repeat: true }); + expect(baseHandler).not.toHaveBeenCalled(); + + unregisterTemporary(); + expect(getActiveShortcutContext()).toEqual({ kind: "timeline" }); + expect(dispatchActiveShortcut({ key: "s" })).toBe("handled"); + expect(baseHandler).toHaveBeenCalledTimes(1); + + unregisterBase(); + expect(getActiveShortcutContext()).toEqual({ kind: "application" }); + }); + + it("classifies text editors and non-text controls without requiring DOM globals", () => { + const targetMatching = (fragment: string) => ({ + closest: (selectors: string) => selectors.includes(fragment) ? {} : null, + }) as unknown as EventTarget; + + const textInput = targetMatching("input[type='text']"); + const select = targetMatching("select"); + const range = targetMatching("input[type='range']"); + const button = targetMatching("button"); + const contentEditable = targetMatching("[contenteditable='true']"); + const generic = targetMatching("[data-not-a-control]"); + + expect(isEditableShortcutTarget(textInput)).toBe(true); + expect(isNonTextControlShortcutTarget(textInput)).toBe(false); + expect(isEditableShortcutTarget(select)).toBe(true); + expect(isNonTextControlShortcutTarget(select)).toBe(false); + expect(isEditableShortcutTarget(range)).toBe(false); + expect(isNonTextControlShortcutTarget(range)).toBe(true); + expect(isEditableShortcutTarget(button)).toBe(false); + expect(isNonTextControlShortcutTarget(button)).toBe(true); + expect(isEditableShortcutTarget(contentEditable)).toBe(true); + expect(isEditableShortcutTarget(generic)).toBe(false); + expect(isNonTextControlShortcutTarget(generic)).toBe(false); + expect(isEditableShortcutTarget(null)).toBe(false); + expect(isNonTextControlShortcutTarget(null)).toBe(false); + }); + + it("requires exact modifiers and preserves native editing chords", () => { + expect(shortcutExactlyMatches({ key: "z", ...hostPrimaryModifier() }, "Ctrl+Z")).toBe(true); + expect(shortcutExactlyMatches( + { key: "z", ...hostPrimaryModifier(), altKey: true }, + "Ctrl+Z", + )).toBe(false); + expect(toPressedShortcut({ key: "ArrowLeft", shiftKey: true })).toBe("Shift+Left"); + expect(shouldPreserveEditableShortcut({ key: "a", ...hostPrimaryModifier() })).toBe(true); + expect(shouldPreserveEditableShortcut({ key: "s", ...hostPrimaryModifier() })).toBe(false); + expect(shouldPreserveEditableShortcut({ key: " " })).toBe(true); + expect(shouldPreserveEditableShortcut({ key: "Enter", altKey: true })).toBe(true); + expect(shouldPreserveEditableShortcut({ key: "Enter", altKey: true }, true)).toBe(false); + expect(shouldPreserveEditableShortcut({ key: "ArrowLeft", ctrlKey: true }, true)).toBe(true); + expect(shouldPreserveNonTextControlShortcut({ key: "ArrowLeft" })).toBe(true); + expect(shouldPreserveNonTextControlShortcut({ key: "l" })).toBe(false); + }); + + it("keeps macOS Command, Control, and Option distinct during exact matching", () => { + expect(canonicalizeShortcutEvent({ key: "z", metaKey: true }, "macos")).toBe("Ctrl+Z"); + expect(canonicalizeShortcutEvent({ key: "z", ctrlKey: true }, "macos")).toBe("Alt+Z"); + expect(canonicalizeShortcutEvent({ + key: "z", + metaKey: true, + ctrlKey: true, + }, "macos")).toBe("Ctrl+Alt+Z"); + expect(canonicalizeShortcutEvent({ key: "z", altKey: true }, "macos")).toBe("Option+Z"); + expect(canonicalizeShortcutEvent({ + key: "z", + ctrlKey: true, + altKey: true, + }, "macos")).toBe("Alt+Option+Z"); + expect(canonicalizeShortcutEvent({ + key: "z", + metaKey: true, + ctrlKey: true, + altKey: true, + }, "macos")).toBe("Ctrl+Alt+Option+Z"); + expect(canonicalizeShortcutEvent({ + key: "z", + ctrlKey: true, + altKey: true, + }, "other")).toBe("Ctrl+Alt+Z"); + expect(canonicalizeShortcutEvent({ key: "Alt", altKey: true }, "macos")).toBeNull(); + expect(canonicalizeShortcutEvent({ key: "å", altKey: true }, "macos")).toBe("Option+Å"); + }); + + it("preserves composition and native macOS text-editing chords", () => { + expect(shouldPreserveEditableShortcut( + { key: "a", metaKey: true }, + false, + "macos", + )).toBe(true); + expect(shouldPreserveEditableShortcut( + { key: "e", ctrlKey: true }, + true, + "macos", + )).toBe(true); + expect(shouldPreserveEditableShortcut( + { key: "e", altKey: true }, + false, + "macos", + )).toBe(true); + expect(shouldPreserveEditableShortcut( + { key: "Enter", altKey: true }, + true, + "macos", + )).toBe(false); + expect(shouldPreserveEditableShortcut({ + key: "@", + ctrlKey: true, + altKey: true, + getModifierState: (modifier) => modifier === "AltGraph", + })).toBe(true); + expect(shouldPreserveEditableShortcut({ key: "z", isComposing: true })).toBe(true); + expect(shouldPreserveNonTextControlShortcut({ key: "Tab" })).toBe(true); + expect(shouldPreserveNonTextControlShortcut({ + key: "ArrowLeft", + ctrlKey: true, + })).toBe(false); + }); + + it("claims empty editor undo without falling through to project history", () => { + const projectUndo = vi.fn(); + useDAWStore.setState({ undo: projectUndo }); + registerShortcutSurface( + { kind: "pitch_editor" }, + (event) => matchesActionShortcut(event, "edit.undo") ? "claimed_noop" : "unmatched", + { kind: "timeline" }, + ); + activateShortcutContext({ kind: "pitch_editor" }); + + expect(dispatchGlobalShortcut({ key: "z", ...hostPrimaryModifier(), source: "browser" })).toBe(true); + expect(projectUndo).not.toHaveBeenCalled(); + }); + + it("does not expose contextual history from application-only surfaces", () => { + const projectUndo = vi.fn(); + useDAWStore.setState({ undo: projectUndo }); + activateShortcutContext({ kind: "application" }); + + expect(dispatchGlobalShortcut({ key: "z", ...hostPrimaryModifier(), source: "browser" })).toBe(false); + expect(projectUndo).not.toHaveBeenCalled(); + }); + + it("uses a custom action binding instead of retaining the default", () => { + const toggleMixer = vi.fn(); + useDAWStore.setState({ + toggleMixer, + customShortcuts: { "view.toggleMixer": "Ctrl+Shift+M" }, + }); + + expect(dispatchGlobalShortcut({ key: "m", ...hostPrimaryModifier(), source: "browser" })).toBe(false); + expect(dispatchGlobalShortcut({ + key: "m", + ...hostPrimaryModifier(), + shiftKey: true, + source: "browser", + })).toBe(true); + expect(toggleMixer).toHaveBeenCalledTimes(1); + }); + + it("dispatches explicit physical-position and Windows-key custom bindings", () => { + const toggleMixer = vi.fn(); + useDAWStore.setState({ + toggleMixer, + customShortcuts: { "view.toggleMixer": "Control+Code:KeyZ" }, + }); + + expect(dispatchGlobalShortcut({ + key: "y", + code: "KeyZ", + ctrlKey: true, + source: "browser", + }, "windows")).toBe(true); + expect(toggleMixer).toHaveBeenCalledTimes(1); + expect(dispatchGlobalShortcut({ + key: "z", + code: "KeyY", + ctrlKey: true, + source: "browser", + }, "windows")).toBe(false); + + useDAWStore.setState({ + customShortcuts: { "view.toggleMixer": "Meta+Code:KeyM" }, + }); + expect(dispatchGlobalShortcut({ + key: "m", + code: "KeyM", + metaKey: true, + source: "browser", + }, "windows")).toBe(true); + expect(toggleMixer).toHaveBeenCalledTimes(2); + expect(dispatchGlobalShortcut({ + key: "m", + code: "KeyM", + ctrlKey: true, + source: "browser", + }, "windows")).toBe(false); + }); + + it("accepts multiple custom bindings at runtime without conflating numpad keys", () => { + const toggleMixer = vi.fn(); + useDAWStore.setState({ + toggleMixer, + customShortcuts: { + "view.toggleMixer": ["Ctrl+M", "Control+Code:KeyZ"], + } as unknown as Record, + }); + + expect(dispatchGlobalShortcut({ + key: "m", + code: "KeyM", + ctrlKey: true, + source: "browser", + }, "windows")).toBe(true); + expect(dispatchGlobalShortcut({ + key: "y", + code: "KeyZ", + ctrlKey: true, + source: "browser", + }, "windows")).toBe(true); + expect(toggleMixer).toHaveBeenCalledTimes(2); + + useDAWStore.setState({ + customShortcuts: { "view.toggleMixer": "Ctrl+1" }, + }); + expect(matchesActionShortcut( + { key: "1", code: "Numpad1", ctrlKey: true, location: 3 }, + "view.toggleMixer", + "windows", + )).toBe(false); + expect(matchesActionShortcut( + { key: "1", code: "Digit1", ctrlKey: true }, + "view.toggleMixer", + "windows", + )).toBe(true); + }); + + it("consumes repeated one-shot bindings but executes repeatable actions", () => { + const toggleMixer = vi.fn(); + const nudgeClips = vi.fn(); + const track = createDefaultTrack("nudge-track", "Nudge", "#123456", "audio", []); + track.clips = [{ + id: "nudge-clip", + filePath: "C:/nudge.wav", + name: "Nudge", + startTime: 1, + duration: 1, + offset: 0, + color: "#123456", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }]; + useDAWStore.setState({ + toggleMixer, + nudgeClips, + tracks: [track], + selectedClipId: "nudge-clip", + selectedClipIds: ["nudge-clip"], + globalLocked: false, + lockSettings: { ...useDAWStore.getState().lockSettings, items: false }, + }); + + expect(dispatchGlobalShortcut({ + key: "m", + ...hostPrimaryModifier(), + repeat: true, + source: "browser", + })).toBe(true); + expect(toggleMixer).not.toHaveBeenCalled(); + + registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + expect(dispatchGlobalShortcut({ + key: "ArrowLeft", + repeat: true, + source: "browser", + })).toBe(true); + expect(nudgeClips).toHaveBeenCalledWith("left"); + }); + + it("lets an editor resolve conflicts before unused keys fall through globally", () => { + const toggleLoop = vi.fn(); + const toggleMixer = vi.fn(); + const pianoHandler = vi.fn((event) => ( + shortcutExactlyMatches(event, "L") ? "handled" as const : "unmatched" as const + )); + useDAWStore.setState({ toggleLoop, toggleMixer }); + registerShortcutSurface( + { kind: "piano_roll", sessionId: "active-editor" }, + pianoHandler, + ); + activateShortcutContext({ kind: "piano_roll", sessionId: "active-editor" }); + + expect(dispatchGlobalShortcut({ key: "l", source: "browser" })).toBe(true); + expect(toggleLoop).not.toHaveBeenCalled(); + + expect(dispatchGlobalShortcut({ key: "m", ...hostPrimaryModifier(), source: "browser" })).toBe(true); + expect(toggleMixer).toHaveBeenCalledTimes(1); + }); + + it("uses active context rather than editor visibility for scoped actions", () => { + const selectAllTracks = vi.fn(); + useDAWStore.setState({ + selectAllTracks, + showPitchEditor: true, + showPianoRoll: true, + selectedNoteIds: ["stale-midi-note"], + }); + registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + + expect(dispatchGlobalShortcut({ key: "a", ...hostPrimaryModifier(), source: "browser" })).toBe(true); + expect(selectAllTracks).toHaveBeenCalledTimes(1); + }); + + it("does not route from stale timeline selection while piano owns the key", () => { + const copySelectedClips = vi.fn(); + useDAWStore.setState({ + copySelectedClips, + selectedClipId: "stale-clip", + selectedClipIds: ["stale-clip"], + selectedNoteIds: [], + }); + registerShortcutSurface( + { kind: "piano_roll", sessionId: "selection-owner" }, + (event) => shortcutExactlyMatches(event, "Ctrl+C") ? "claimed_noop" : "unmatched", + ); + activateShortcutContext({ kind: "piano_roll", sessionId: "selection-owner" }); + + expect(dispatchGlobalShortcut({ key: "c", ...hostPrimaryModifier(), source: "browser" })).toBe(true); + expect(copySelectedClips).not.toHaveBeenCalled(); + }); + + it("leaves native input editing alone while allowing application shortcuts", () => { + const saveProject = vi.fn(); + const selectAllTracks = vi.fn(); + useDAWStore.setState({ saveProject, selectAllTracks }); + registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + + expect(dispatchGlobalShortcut({ + key: "a", + ...hostPrimaryModifier(), + source: "browser", + targetIsEditable: true, + })).toBe(false); + expect(selectAllTracks).not.toHaveBeenCalled(); + + expect(dispatchGlobalShortcut({ + key: "s", + ...hostPrimaryModifier(), + source: "browser", + targetIsEditable: true, + })).toBe(true); + expect(saveProject).toHaveBeenCalledTimes(1); + }); + + it("allows an exact registered modifier chord from a focused input", () => { + const openProjectSettings = vi.fn(); + useDAWStore.setState({ openProjectSettings }); + + expect(dispatchGlobalShortcut({ + key: "Enter", + ...hostLegacySecondaryModifier(), + source: "browser", + targetIsEditable: true, + })).toBe(true); + expect(openProjectSettings).toHaveBeenCalledTimes(1); + }); + + it("reserves the effective transport Play binding while preserving other native control keys", () => { + const toggleLoop = vi.fn(); + const executeAction = vi.fn(); + const preventDefault = vi.fn(); + useDAWStore.setState({ toggleLoop }); + activateShortcutContext({ kind: "application" }); + + expect(dispatchGlobalShortcut({ + key: "l", + source: "browser", + targetIsNonTextControl: true, + })).toBe(true); + expect(toggleLoop).toHaveBeenCalledTimes(1); + + expect(dispatchGlobalShortcut({ + key: " ", + code: "Space", + source: "browser", + targetIsNonTextControl: true, + preventDefault, + }, "windows", { executeAction })).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(executeAction).toHaveBeenCalledOnce(); + expect(executeAction.mock.calls[0][0].id).toBe("transport.play"); + }); +}); diff --git a/frontend/src/__tests__/shortcutDispatcherPlatform.test.ts b/frontend/src/__tests__/shortcutDispatcherPlatform.test.ts new file mode 100644 index 0000000..4316db9 --- /dev/null +++ b/frontend/src/__tests__/shortcutDispatcherPlatform.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; +import { + dispatchGlobalShortcut, + matchesActionShortcut, +} from "../utils/globalShortcutDispatcher"; +import { + activateShortcutContext, + registerShortcutSurface, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; + +describe("platform-explicit shortcut dispatch", () => { + const original = { + keyboardShortcutProfileId: useDAWStore.getState().keyboardShortcutProfileId, + customShortcuts: useDAWStore.getState().customShortcuts, + tracks: useDAWStore.getState().tracks, + selectedClipId: useDAWStore.getState().selectedClipId, + selectedClipIds: useDAWStore.getState().selectedClipIds, + transport: useDAWStore.getState().transport, + splitClipAtPlayhead: useDAWStore.getState().splitClipAtPlayhead, + }; + + beforeEach(() => { + resetShortcutContextForTests(); + useDAWStore.setState({ + keyboardShortcutProfileId: "pro_tools", + customShortcuts: {}, + selectedClipIds: ["selected-clip"], + }); + registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + }); + + afterEach(() => { + resetShortcutContextForTests(); + useDAWStore.setState(original); + }); + + it("matches the portable primary modifier as Control on Windows and Command on macOS", () => { + const windowsEvent = { key: "e", code: "KeyE", ctrlKey: true }; + const macEvent = { key: "e", code: "KeyE", metaKey: true }; + + expect(matchesActionShortcut(windowsEvent, "edit.splitAtCursor", "windows")).toBe(true); + expect(matchesActionShortcut(windowsEvent, "edit.splitAtCursor", "macos")).toBe(false); + expect(matchesActionShortcut(macEvent, "edit.splitAtCursor", "macos")).toBe(true); + expect(matchesActionShortcut(macEvent, "edit.splitAtCursor", "windows")).toBe(false); + }); + + it("runs the real scoped registry action with an explicit target platform", () => { + const splitClipAtPlayhead = vi.fn(); + const track = createDefaultTrack("split-track", "Split", "#123456", "audio", []); + track.clips = [{ + id: "selected-clip", + filePath: "C:/split.wav", + name: "Split", + startTime: 0, + duration: 2, + offset: 0, + color: "#123456", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }]; + useDAWStore.setState((state) => ({ + splitClipAtPlayhead, + tracks: [track], + selectedClipId: "selected-clip", + selectedClipIds: ["selected-clip"], + transport: { ...state.transport, currentTime: 1 }, + })); + + expect(dispatchGlobalShortcut( + { key: "e", code: "KeyE", metaKey: true, source: "test" }, + "macos", + )).toBe(true); + expect(splitClipAtPlayhead).toHaveBeenCalledTimes(1); + + expect(dispatchGlobalShortcut( + { key: "e", code: "KeyE", metaKey: true, source: "test" }, + "windows", + )).toBe(false); + expect(splitClipAtPlayhead).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/__tests__/shortcutPlatformNormalization.test.ts b/frontend/src/__tests__/shortcutPlatformNormalization.test.ts new file mode 100644 index 0000000..3c3cab6 --- /dev/null +++ b/frontend/src/__tests__/shortcutPlatformNormalization.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalizeShortcutEvent, + formatShortcutForPlatform, + normalizeShortcutBinding, + normalizeShortcutBindings, + shortcutBindingEventSignature, + shortcutEventCandidates, + shortcutMatchesEvent, +} from "../utils/platform"; + +describe("cross-platform shortcut normalization", () => { + it("keeps legacy bindings compatible while exposing physical modifiers", () => { + expect(canonicalizeShortcutEvent( + { key: "z", code: "KeyZ", metaKey: true }, + "macos", + )).toBe("Ctrl+Z"); + expect(canonicalizeShortcutEvent( + { key: "z", code: "KeyZ", metaKey: true }, + "macos", + { modifierStyle: "physical" }, + )).toBe("Command+Z"); + expect(canonicalizeShortcutEvent( + { key: "z", code: "KeyZ", ctrlKey: true }, + "macos", + )).toBe("Alt+Z"); + expect(canonicalizeShortcutEvent( + { key: "z", code: "KeyZ", ctrlKey: true }, + "macos", + { modifierStyle: "physical" }, + )).toBe("Control+Z"); + expect(canonicalizeShortcutEvent( + { key: "z", code: "KeyZ", altKey: true }, + "macos", + )).toBe("Option+Z"); + + expect(shortcutMatchesEvent( + { key: "z", code: "KeyZ", metaKey: true }, + "Ctrl+Z", + "macos", + )).toBe(true); + expect(shortcutMatchesEvent( + { key: "z", code: "KeyZ", metaKey: true }, + "Command+Z", + "macos", + )).toBe(true); + expect(shortcutMatchesEvent( + { key: "z", code: "KeyZ", metaKey: true }, + "Control+Z", + "macos", + )).toBe(false); + }); + + it("does not alias the Windows key to Control", () => { + const windowsKeyEvent = { key: "m", code: "KeyM", metaKey: true }; + expect(canonicalizeShortcutEvent(windowsKeyEvent, "windows")).toBe("Meta+M"); + expect(shortcutMatchesEvent(windowsKeyEvent, "Meta+M", "windows")).toBe(true); + expect(shortcutMatchesEvent(windowsKeyEvent, "Ctrl+M", "windows")).toBe(false); + expect(shortcutMatchesEvent( + { key: "m", code: "KeyM", ctrlKey: true }, + "Meta+M", + "windows", + )).toBe(false); + }); + + it("requires exact modifier sets, including Option and mixed Mac chords", () => { + const commandControlOption = { + key: "r", + code: "KeyR", + ctrlKey: true, + altKey: true, + metaKey: true, + shiftKey: true, + }; + + expect(canonicalizeShortcutEvent(commandControlOption, "macos")).toBe( + "Ctrl+Alt+Option+Shift+R", + ); + expect(shortcutMatchesEvent( + commandControlOption, + "Command+Control+Option+Shift+R", + "macos", + )).toBe(true); + expect(shortcutMatchesEvent( + commandControlOption, + "Ctrl+Alt+Shift+R", + "macos", + )).toBe(false); + expect(shortcutMatchesEvent( + { key: "r", code: "KeyR", altKey: true }, + "Alt+R", + "macos", + )).toBe(false); + expect(shortcutMatchesEvent( + { key: "r", code: "KeyR", altKey: true }, + "Option+R", + "macos", + )).toBe(true); + }); + + it("supports printed-label and physical-position bindings on QWERTZ", () => { + // The key labelled Z on a German QWERTZ layout reports KeyY. + const labelledZ = { key: "z", code: "KeyY", ctrlKey: true }; + expect(shortcutMatchesEvent(labelledZ, "Ctrl+Z", "windows")).toBe(true); + expect(shortcutMatchesEvent( + labelledZ, + "Control+Code:KeyY", + "windows", + )).toBe(true); + expect(shortcutMatchesEvent(labelledZ, "Ctrl+Code:KeyZ", "windows")).toBe(false); + expect(canonicalizeShortcutEvent( + labelledZ, + "windows", + { keyMode: "physical", modifierStyle: "physical" }, + )).toBe("Control+Code:KeyY"); + + // The US-QWERTY Z position reports KeyZ but produces Y on QWERTZ. + const qwertyZPosition = { key: "y", code: "KeyZ", ctrlKey: true }; + expect(shortcutMatchesEvent(qwertyZPosition, "Ctrl+Z", "windows")).toBe(false); + expect(shortcutMatchesEvent( + qwertyZPosition, + "Control+Code:KeyZ", + "windows", + )).toBe(true); + }); + + it("supports printed-label and physical-position bindings on AZERTY", () => { + const labelledQ = { key: "q", code: "KeyA", metaKey: true }; + expect(shortcutMatchesEvent(labelledQ, "Command+Q", "macos")).toBe(true); + expect(shortcutMatchesEvent( + labelledQ, + "Command+Code:KeyA", + "macos", + )).toBe(true); + expect(shortcutMatchesEvent( + labelledQ, + "Command+Code:KeyQ", + "macos", + )).toBe(false); + }); + + it("distinguishes numpad keys from the top row", () => { + const topRowOne = { key: "1", code: "Digit1", ctrlKey: true }; + const numpadOne = { key: "1", code: "Numpad1", ctrlKey: true, location: 3 }; + + expect(canonicalizeShortcutEvent(topRowOne, "windows")).toBe("Ctrl+1"); + expect(canonicalizeShortcutEvent(numpadOne, "windows")).toBe("Ctrl+Numpad1"); + expect(shortcutMatchesEvent(topRowOne, "Ctrl+1", "windows")).toBe(true); + expect(shortcutMatchesEvent(topRowOne, "Ctrl+Numpad1", "windows")).toBe(false); + expect(shortcutMatchesEvent(numpadOne, "Ctrl+1", "windows")).toBe(false); + expect(shortcutMatchesEvent(numpadOne, "Ctrl+Numpad1", "windows")).toBe(true); + expect(shortcutMatchesEvent( + numpadOne, + "Control+Code:Numpad1", + "windows", + )).toBe(true); + + expect(canonicalizeShortcutEvent( + { key: "+", code: "NumpadAdd", location: 3 }, + "windows", + )).toBe("NumpadAdd"); + expect(canonicalizeShortcutEvent( + { key: "2", location: 3 }, + "windows", + )).toBe("Numpad2"); + }); + + it("keeps AltGraph text entry from masquerading as Ctrl+Alt", () => { + const altGraphEvent = { + key: "@", + code: "KeyQ", + ctrlKey: true, + altKey: true, + getModifierState: (modifier: string) => modifier === "AltGraph", + }; + expect(canonicalizeShortcutEvent(altGraphEvent, "windows")).toBe("AltGraph+@"); + expect(shortcutMatchesEvent(altGraphEvent, "Ctrl+Alt+@", "windows")).toBe(false); + expect(shortcutMatchesEvent(altGraphEvent, "AltGraph+@", "windows")).toBe(true); + expect(shortcutMatchesEvent( + altGraphEvent, + "AltGraph+Code:KeyQ", + "windows", + )).toBe(true); + }); + + it("rejects modifier-only, composing, dead, and unidentified input", () => { + expect(canonicalizeShortcutEvent( + { key: "Control", code: "ControlLeft", ctrlKey: true }, + "windows", + )).toBeNull(); + expect(canonicalizeShortcutEvent( + { key: "z", code: "KeyZ", ctrlKey: true, isComposing: true }, + "windows", + )).toBeNull(); + expect(canonicalizeShortcutEvent({ key: "Dead", code: "Quote" }, "windows")).toBeNull(); + expect(shortcutMatchesEvent( + { key: "Dead", code: "Quote" }, + "Code:Quote", + "windows", + )).toBe(false); + expect(canonicalizeShortcutEvent({ key: "Unidentified" }, "windows")).toBeNull(); + }); + + it("normalizes aliases, plus keys, and multiple bindings", () => { + expect(normalizeShortcutBinding(" shift + cmd + code:keyz ")).toBe( + "Command+Shift+Code:KeyZ", + ); + expect(normalizeShortcutBinding("windows+control+numpadadd")).toBe( + "Control+Meta+NumpadAdd", + ); + expect(normalizeShortcutBinding("ctrl++")).toBe("Ctrl++"); + expect(normalizeShortcutBinding("ctrl+ctrl+z")).toBeNull(); + expect(normalizeShortcutBindings([ + "ctrl+z", + "Ctrl+Z", + null, + "Command+Code:KeyZ", + "", + ])).toEqual(["Ctrl+Z", "Command+Code:KeyZ"]); + }); + + it("returns both legacy and explicit event candidates", () => { + const candidates = shortcutEventCandidates( + { key: "z", code: "KeyZ", metaKey: true }, + "macos", + ); + expect(candidates).toContain("Ctrl+Z"); + expect(candidates).toContain("Command+Z"); + expect(candidates).toContain("Ctrl+Code:KeyZ"); + expect(candidates).toContain("Command+Code:KeyZ"); + }); + + it("builds platform-physical signatures for truthful conflict checks", () => { + expect(shortcutBindingEventSignature("Ctrl+Z", "macos")).toBe( + shortcutBindingEventSignature("Command+Z", "macos"), + ); + expect(shortcutBindingEventSignature("Alt+Z", "macos")).toBe( + shortcutBindingEventSignature("Control+Z", "macos"), + ); + expect(shortcutBindingEventSignature("Option+Z", "macos")).not.toBe( + shortcutBindingEventSignature("Alt+Z", "macos"), + ); + expect(shortcutBindingEventSignature("Option+Z", "windows")).toBeNull(); + expect(shortcutBindingEventSignature("Ctrl+Control+Z", "windows")).toBeNull(); + expect(shortcutBindingEventSignature("Control+Code:KeyB", "windows")).toBe( + shortcutBindingEventSignature("Ctrl+B", "windows"), + ); + }); + + it("formats legacy and explicit bindings for each platform", () => { + expect(formatShortcutForPlatform("Ctrl+Alt+Option+Z", "macos")).toBe( + "Cmd+Ctrl+Option+Z", + ); + expect(formatShortcutForPlatform("Control+Meta+M", "windows")).toBe( + "Ctrl+Win+M", + ); + expect(formatShortcutForPlatform("Command+Code:KeyZ", "macos")).toBe( + "Cmd+Physical KeyZ", + ); + }); +}); diff --git a/frontend/src/__tests__/shortcutProfileDispatch.test.ts b/frontend/src/__tests__/shortcutProfileDispatch.test.ts new file mode 100644 index 0000000..b7d6c1c --- /dev/null +++ b/frontend/src/__tests__/shortcutProfileDispatch.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getDisplayEffectiveShortcut } from "../store/actionRegistry"; +import { useDAWStore, type Track } from "../store/useDAWStore"; +import { + dispatchGlobalShortcut, + matchesActionShortcut, +} from "../utils/globalShortcutDispatcher"; +import { + activateShortcutContext, + registerShortcutSurface, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; +import { getShortcutPlatform } from "../utils/platform"; + +function hostPrimaryModifier(): { ctrlKey: true } | { metaKey: true } { + return getShortcutPlatform() === "macos" ? { metaKey: true } : { ctrlKey: true }; +} + +describe("runtime shortcut profile dispatch", () => { + const original = { + keyboardShortcutProfileId: useDAWStore.getState().keyboardShortcutProfileId, + customShortcuts: useDAWStore.getState().customShortcuts, + transport: useDAWStore.getState().transport, + toggleRecord: useDAWStore.getState().toggleRecord, + toggleTrackArmed: useDAWStore.getState().toggleTrackArmed, + toggleTrackMute: useDAWStore.getState().toggleTrackMute, + toggleTrackSolo: useDAWStore.getState().toggleTrackSolo, + toggleSelectedTracksArmed: useDAWStore.getState().toggleSelectedTracksArmed, + toggleSelectedTracksMute: useDAWStore.getState().toggleSelectedTracksMute, + toggleSelectedTracksSolo: useDAWStore.getState().toggleSelectedTracksSolo, + toggleStepInput: useDAWStore.getState().toggleStepInput, + selectedTrackIds: useDAWStore.getState().selectedTrackIds, + tracks: useDAWStore.getState().tracks, + }; + + beforeEach(() => { + useDAWStore.setState({ keyboardShortcutProfileId: "openstudio", customShortcuts: {} }); + }); + + afterEach(() => { + resetShortcutContextForTests(); + useDAWStore.setState(original); + }); + + it("switches matching immediately without rewriting factory shortcuts", () => { + useDAWStore.setState({ keyboardShortcutProfileId: "pro_tools" }); + expect(matchesActionShortcut({ key: "e", code: "KeyE", ...hostPrimaryModifier() }, "edit.splitAtCursor")).toBe(true); + expect(matchesActionShortcut({ key: "s", code: "KeyS" }, "edit.splitAtCursor")).toBe(false); + }); + + it("lets a custom binding override the active profile", () => { + useDAWStore.setState({ + keyboardShortcutProfileId: "pro_tools", + customShortcuts: { "edit.splitAtCursor": "Code:KeyK" }, + }); + expect(matchesActionShortcut({ key: "k", code: "KeyK" }, "edit.splitAtCursor")).toBe(true); + expect(matchesActionShortcut({ key: "e", code: "KeyE", ...hostPrimaryModifier() }, "edit.splitAtCursor")).toBe(false); + }); + + it("keeps physical key profiles stable when the produced layout label changes", () => { + useDAWStore.setState({ customShortcuts: { "tools.splitTool": "Code:KeyB" } }); + expect(matchesActionShortcut({ key: "x", code: "KeyB" }, "tools.splitTool")).toBe(true); + }); + + it("treats an explicit empty custom binding as unassigned", () => { + useDAWStore.setState({ + keyboardShortcutProfileId: "pro_tools", + customShortcuts: { "edit.splitAtCursor": "" }, + }); + expect(matchesActionShortcut({ key: "e", code: "KeyE", ...hostPrimaryModifier() }, "edit.splitAtCursor")).toBe(false); + expect(getDisplayEffectiveShortcut("edit.splitAtCursor")).toBe(""); + }); + + it("does not let factory-scoped R tools shadow a profile's global Record", () => { + const toggleRecord = vi.fn(); + const toggleTrackArmed = vi.fn(); + const current = useDAWStore.getState(); + useDAWStore.setState({ + keyboardShortcutProfileId: "garageband", + transport: { ...current.transport, isRecording: true }, + toggleRecord, + toggleTrackArmed, + }); + registerShortcutSurface({ kind: "piano_roll", sessionId: "profile-record-test" }, () => "unmatched"); + activateShortcutContext({ kind: "piano_roll", sessionId: "profile-record-test" }); + + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "transport.record")).toBe(true); + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "track.toggleSelectedArm")).toBe(false); + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "midi.tool.range")).toBe(false); + expect(dispatchGlobalShortcut({ key: "r", code: "KeyR", source: "browser" })).toBe(true); + expect(toggleRecord).toHaveBeenCalledTimes(1); + expect(toggleTrackArmed).not.toHaveBeenCalled(); + }); + + it("makes GarageBand selected-track M/S commands reachable from Timeline focus", () => { + const toggleSelectedTracksMute = vi.fn(() => true); + const toggleSelectedTracksSolo = vi.fn(() => true); + useDAWStore.setState({ + keyboardShortcutProfileId: "garageband", + selectedTrackIds: ["track-1"], + toggleSelectedTracksMute, + toggleSelectedTracksSolo, + }); + registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + + expect(matchesActionShortcut({ key: "m", code: "KeyM" }, "insert.marker")).toBe(false); + expect(matchesActionShortcut({ key: "s", code: "KeyS" }, "edit.splitAtCursor")).toBe(false); + expect(dispatchGlobalShortcut({ key: "m", code: "KeyM", source: "browser" })).toBe(true); + expect(dispatchGlobalShortcut({ key: "s", code: "KeyS", source: "browser" })).toBe(true); + expect(toggleSelectedTracksMute).toHaveBeenCalledTimes(1); + expect(toggleSelectedTracksSolo).toHaveBeenCalledTimes(1); + }); + + it("makes Cakewalk Alt+M/S/R selected-track commands reachable from Timeline focus", () => { + const toggleSelectedTracksMute = vi.fn(() => true); + const toggleSelectedTracksSolo = vi.fn(() => true); + const toggleSelectedTracksArmed = vi.fn(() => true); + const track = { id: "track-1", armed: false, recordSafe: false } as Track; + useDAWStore.setState({ + keyboardShortcutProfileId: "cakewalk_sonar", + selectedTrackIds: [track.id], + tracks: [track], + toggleSelectedTracksMute, + toggleSelectedTracksSolo, + toggleSelectedTracksArmed, + }); + registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + + for (const key of ["m", "s", "r"] as const) { + expect(dispatchGlobalShortcut({ key, code: `Key${key.toUpperCase()}`, altKey: true, source: "browser" })).toBe(true); + } + expect(toggleSelectedTracksMute).toHaveBeenCalledTimes(1); + expect(toggleSelectedTracksSolo).toHaveBeenCalledTimes(1); + expect(toggleSelectedTracksArmed).toHaveBeenCalledTimes(1); + }); + + it("does not let scoped R fallbacks steal REAPER loop or Pro Tools zoom", () => { + useDAWStore.setState({ keyboardShortcutProfileId: "reaper" }); + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "transport.loop")).toBe(true); + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "track.toggleSelectedArm")).toBe(false); + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "midi.tool.range")).toBe(false); + + useDAWStore.setState({ keyboardShortcutProfileId: "pro_tools" }); + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "view.zoomOut")).toBe(true); + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "track.toggleSelectedArm")).toBe(false); + expect(matchesActionShortcut({ key: "r", code: "KeyR" }, "midi.tool.range")).toBe(false); + }); + + it.each(["macos", "windows"] as const)("does not equate Renoise Edit Mode Esc with Piano Roll step input on %s", (platform) => { + const toggleStepInput = vi.fn(); + useDAWStore.setState({ + keyboardShortcutProfileId: "renoise", + toggleStepInput, + }); + registerShortcutSurface({ kind: "piano_roll", sessionId: `renoise-${platform}` }, () => "unmatched"); + activateShortcutContext({ kind: "piano_roll", sessionId: `renoise-${platform}` }); + + expect(matchesActionShortcut( + { key: "Escape", code: "Escape" }, + "midi.closeEditor", + platform, + )).toBe(false); + expect(matchesActionShortcut( + { key: "Escape", code: "Escape" }, + "midi.toggleStepInput", + platform, + )).toBe(false); + expect(dispatchGlobalShortcut( + { key: "Escape", code: "Escape", source: "browser" }, + platform, + )).toBe(false); + expect(toggleStepInput).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/shortcutProfiles.test.ts b/frontend/src/__tests__/shortcutProfiles.test.ts new file mode 100644 index 0000000..c9ab69a --- /dev/null +++ b/frontend/src/__tests__/shortcutProfiles.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from "vitest"; +import { getActionShortcutScopes, getRegisteredActions } from "../store/actionRegistry"; +import { + normalizeShortcutBinding, + shortcutBindingEventSignature, + shortcutMatchesEvent, +} from "../utils/platform"; +import { + getKeyboardShortcutProfile, + getKeyboardShortcutProfilePresentation, + getProfileActionBindings, + KEYBOARD_SHORTCUT_PROFILE_IDS, + KEYBOARD_SHORTCUT_PROFILES, +} from "../utils/shortcutProfiles"; + +describe("keyboard shortcut profiles", () => { + it("provides every required and additional DAW as a stable unique profile", () => { + expect(new Set(KEYBOARD_SHORTCUT_PROFILE_IDS).size).toBe(KEYBOARD_SHORTCUT_PROFILE_IDS.length); + expect(KEYBOARD_SHORTCUT_PROFILE_IDS).toEqual(expect.arrayContaining([ + "openstudio", + "pro_tools", + "cubase", + "reaper", + "audacity", + "logic_pro", + "fl_studio", + "ableton_live", + "studio_one", + "bitwig_studio", + "reason", + "cakewalk_sonar", + "garageband", + "digital_performer", + "ardour", + "adobe_audition", + "mixcraft", + "waveform", + "renoise", + ])); + }); + + it("marks source-DAW platform availability without disabling portable profile use", () => { + expect(getKeyboardShortcutProfile("cakewalk_sonar").nativePlatforms).toEqual(["windows"]); + expect(getKeyboardShortcutProfile("garageband").nativePlatforms).toEqual(["macos"]); + expect(getKeyboardShortcutProfile("digital_performer").nativePlatforms).toEqual(["macos", "windows"]); + expect(getKeyboardShortcutProfile("adobe_audition").nativePlatforms).toEqual(["macos", "windows"]); + expect(getKeyboardShortcutProfile("mixcraft").nativePlatforms).toEqual(["windows"]); + expect(getKeyboardShortcutProfile("waveform").nativePlatforms).toEqual(["macos", "windows", "linux"]); + expect(getKeyboardShortcutProfile("renoise").nativePlatforms).toEqual(["macos", "windows", "linux"]); + }); + + it("extends selected-track commands into the Timeline only for profiles that document them", () => { + const actions = getRegisteredActions(); + const mute = actions.find((action) => action.id === "track.toggleSelectedMute")!; + const solo = actions.find((action) => action.id === "track.toggleSelectedSolo")!; + const arm = actions.find((action) => action.id === "track.toggleSelectedArm")!; + + expect(getActionShortcutScopes(mute, "garageband")).toContain("timeline"); + expect(getActionShortcutScopes(solo, "garageband")).toContain("timeline"); + expect(getActionShortcutScopes(arm, "garageband")).toContain("timeline"); + expect(getActionShortcutScopes(arm, "cakewalk_sonar")).toContain("timeline"); + expect(getActionShortcutScopes(mute, "openstudio")).not.toContain("timeline"); + }); + + it("references registered actions and parseable bindings", () => { + const actionIds = new Set(getRegisteredActions().map((action) => action.id)); + for (const profile of KEYBOARD_SHORTCUT_PROFILES) { + for (const [actionId, scopes] of Object.entries(profile.scopeAdditions ?? {})) { + expect(actionIds.has(actionId), `${profile.id} scope addition: ${actionId}`).toBe(true); + expect(scopes.length, `${profile.id} scope addition: ${actionId}`).toBeGreaterThan(0); + expect(new Set(scopes).size, `${profile.id} scope addition: ${actionId}`).toBe(scopes.length); + } + for (const actionId of Object.keys(profile.bindings)) { + expect(actionIds.has(actionId), `${profile.id}: ${actionId}`).toBe(true); + for (const platform of ["macos", "windows", "linux", "other"] as const) { + const bindings = getProfileActionBindings(profile.id, actionId, platform) ?? []; + for (const binding of bindings) { + expect(normalizeShortcutBinding(binding), `${profile.id}/${platform}: ${binding}`).toBeTruthy(); + } + } + } + } + }); + + it("maps Logic Option nudges to physical Alt on Windows without changing macOS Option", () => { + expect(getProfileActionBindings("logic_pro", "edit.nudgeLeft", "macos")).toEqual(["Option+Left"]); + expect(getProfileActionBindings("logic_pro", "edit.nudgeLeft", "windows")).toEqual(["Alt+Left"]); + }); + + it("ports Cakewalk's documented Alt track commands to Option on macOS", () => { + expect(getProfileActionBindings("cakewalk_sonar", "track.toggleSelectedMute", "windows")).toEqual(["Alt+M"]); + expect(getProfileActionBindings("cakewalk_sonar", "track.toggleSelectedMute", "macos")).toEqual(["Option+M"]); + expect(getProfileActionBindings("cakewalk_sonar", "track.toggleSelectedSolo", "windows")).toEqual(["Alt+S"]); + expect(getProfileActionBindings("cakewalk_sonar", "track.toggleSelectedArm", "macos")).toEqual(["Option+R"]); + }); + + it("maps documented Cakewalk and GarageBand commands only to registered actions", () => { + expect(getProfileActionBindings("cakewalk_sonar", "transport.record", "windows")).toEqual(["R"]); + expect(getProfileActionBindings("cakewalk_sonar", "transport.metronome", "windows")).toEqual(["Ctrl+F3"]); + expect(getProfileActionBindings("cakewalk_sonar", "tools.smartTool", "windows")).toEqual(["F5"]); + expect(getProfileActionBindings("cakewalk_sonar", "tools.selectTool", "windows")).toEqual(["F6"]); + expect(getProfileActionBindings("cakewalk_sonar", "view.toggleSnap", "windows")).toEqual(["N"]); + expect(getProfileActionBindings("cakewalk_sonar", "view.toggleMixer", "windows")).toEqual(["Alt+2"]); + expect(getProfileActionBindings("cakewalk_sonar", "clip.openSelectedInPianoRoll", "macos")).toEqual(["Option+3"]); + expect(getProfileActionBindings("cakewalk_sonar", "edit.nudgeLeft", "windows")).toEqual(["Numpad1"]); + expect(getProfileActionBindings("cakewalk_sonar", "edit.nudgeRight", "windows")).toEqual(["Numpad3"]); + expect(getProfileActionBindings("cakewalk_sonar", "edit.muteClips", "windows")).toEqual(["K"]); + expect(getProfileActionBindings("cakewalk_sonar", "edit.toggleClipLock", "windows")).toEqual(["Ctrl+K"]); + expect(getProfileActionBindings("cakewalk_sonar", "view.zoomToFit", "windows")).toEqual(["Ctrl+F"]); + expect(getProfileActionBindings("cakewalk_sonar", "options.preferences", "windows")).toEqual(["P"]); + + expect(getProfileActionBindings("garageband", "transport.loop", "macos")).toEqual(["C"]); + expect(getProfileActionBindings("garageband", "transport.metronome", "macos")).toEqual(["K"]); + expect(getProfileActionBindings("garageband", "view.toggleSnap", "macos")).toEqual(["Ctrl+G"]); + expect(getProfileActionBindings("garageband", "view.zoomOut", "macos")).toEqual(["Ctrl+Left"]); + expect(getProfileActionBindings("garageband", "view.zoomIn", "windows")).toEqual(["Ctrl+Right"]); + expect(getProfileActionBindings("garageband", "track.toggleSelectedArm", "macos")).toEqual(["Control+R"]); + expect(getProfileActionBindings("garageband", "track.toggleSelectedMonitor", "windows")).toEqual(["Ctrl+I"]); + expect(getProfileActionBindings("reason", "transport.metronome", "windows")).toEqual(["C"]); + }); + + it("keeps physical Option and Control commands distinct on macOS", () => { + expect(getProfileActionBindings("cubase", "view.toggleVirtualKeyboard", "macos")).toEqual(["Option+K"]); + expect(getProfileActionBindings("logic_pro", "edit.muteClips", "macos")).toEqual(["Control+M"]); + expect(shortcutMatchesEvent( + { key: "k", code: "KeyK", altKey: true }, + "Option+K", + "macos", + )).toBe(true); + expect(shortcutMatchesEvent( + { key: "m", code: "KeyM", ctrlKey: true }, + "Control+M", + "macos", + )).toBe(true); + }); + + it.each([ + ["pro_tools", "edit.groupClips", ["Ctrl+Option+G"], ["Ctrl+Alt+G"]], + ["pro_tools", "edit.ungroupClips", ["Ctrl+Option+U"], ["Ctrl+Alt+U"]], + ["cubase", "edit.splitAtCursor", ["Option+X"], ["Alt+X"]], + ["cubase", "view.zoomToSelection", ["Option+S"], ["Alt+S"]], + ["reaper", "edit.reverseClip", ["Option+R"], ["Alt+R"]], + ["audacity", "transport.pause", ["P"], ["P"]], + ["logic_pro", "edit.splitAtSelection", ["Option+Ctrl+T"], ["Alt+Ctrl+T"]], + ["logic_pro", "edit.reverseClip", ["Control+Shift+R"], ["Ctrl+Shift+R"]], + ["fl_studio", "midi.quantizeLast", ["Ctrl+Q"], ["Ctrl+Q"]], + ["fl_studio", "view.toggleSnap", ["Backspace"], ["Backspace"]], + ["ableton_live", "midi.tool.draw", ["B"], ["B"]], + ["ableton_live", "insert.emptyMidiClip", ["Ctrl+Shift+M"], ["Ctrl+Shift+M"]], + ["studio_one", "insert.multipleTracks", ["T"], ["T"]], + ["studio_one", "edit.normalizeClips", ["Option+N"], ["Alt+N"]], + ["bitwig_studio", "edit.splitAtCursor", ["Ctrl+E"], ["Ctrl+E"]], + ["reason", "edit.splitAtCursor", ["Option+X"], ["Alt+X"]], + ["cakewalk_sonar", "view.mediaExplorer", ["B"], ["B"]], + ["garageband", "edit.splitAtCursor", ["Ctrl+T"], ["Ctrl+T"]], + ["ardour", "tools.selectTool", ["G"], ["G"]], + ["adobe_audition", "edit.nudgeLeft", ["Option+,"], ["Alt+,"]], + ["mixcraft", "edit.splitAtCursor", ["Ctrl+T"], ["Ctrl+T"]], + ["waveform", "view.zoomIn", ["Up"], ["Up"]], + ["renoise", "transport.play", ["Space"], ["Space"]], + ] as const)("maps %s %s on both desktop platforms", (profileId, actionId, macos, windows) => { + expect(getProfileActionBindings(profileId, actionId, "macos")).toEqual(macos); + expect(getProfileActionBindings(profileId, actionId, "windows")).toEqual(windows); + }); + + it("uses event-reachable main-row and numpad zoom bindings", () => { + expect(shortcutMatchesEvent( + { key: "=", code: "Equal", ctrlKey: true }, + "Ctrl+=", + "windows", + )).toBe(true); + expect(shortcutMatchesEvent( + { key: "+", code: "Equal", shiftKey: true }, + "Shift++", + "windows", + )).toBe(true); + expect(shortcutMatchesEvent( + { key: "+", code: "NumpadAdd", location: 3 }, + "NumpadAdd", + "windows", + )).toBe(true); + }); + + it("explicitly suppresses dangerous inherited bindings with no native equivalent", () => { + expect(getProfileActionBindings("pro_tools", "tools.splitTool", "windows")).toEqual([]); + expect(getProfileActionBindings("fl_studio", "transport.loop", "windows")).toEqual([]); + expect(getProfileActionBindings("fl_studio", "file.new", "macos")).toEqual([]); + expect(getProfileActionBindings("ableton_live", "tools.splitTool", "macos")).toEqual([]); + expect(getProfileActionBindings("ableton_live", "insert.marker", "windows")).toEqual([]); + expect(getProfileActionBindings("logic_pro", "tools.selectTool", "macos")).toEqual([]); + expect(getProfileActionBindings("logic_pro", "tools.splitTool", "macos")).toEqual([]); + expect(getProfileActionBindings("pro_tools", "track.toggleSelectedArm", "windows")).toEqual([]); + expect(getProfileActionBindings("pro_tools", "midi.tool.range", "macos")).toEqual([]); + expect(getProfileActionBindings("reaper", "track.toggleSelectedArm", "macos")).toEqual([]); + expect(getProfileActionBindings("reaper", "midi.tool.range", "windows")).toEqual([]); + expect(getProfileActionBindings("garageband", "edit.groupClips", "macos")).toEqual([]); + expect(getProfileActionBindings("garageband", "edit.nudgeLeftFine", "macos")).toEqual([]); + expect(getProfileActionBindings("garageband", "edit.nudgeRightFine", "windows")).toEqual([]); + expect(getProfileActionBindings("cakewalk_sonar", "tools.splitTool", "windows")).toEqual([]); + expect(getProfileActionBindings("cakewalk_sonar", "tools.muteTool", "windows")).toEqual([]); + expect(getProfileActionBindings("cakewalk_sonar", "edit.nudgeLeftFine", "windows")).toEqual([]); + expect(getProfileActionBindings("cakewalk_sonar", "edit.nudgeRightFine", "macos")).toEqual([]); + expect(getProfileActionBindings("cakewalk_sonar", "edit.editPitch", "windows")).toEqual([]); + for (const profileId of ["audacity", "logic_pro", "fl_studio", "bitwig_studio"] as const) { + expect(getProfileActionBindings(profileId, "track.toggleSelectedArm", "windows"), profileId).toEqual([]); + expect(getProfileActionBindings(profileId, "track.toggleSelectedArm", "macos"), profileId).toEqual([]); + expect(getProfileActionBindings(profileId, "midi.tool.range", "windows"), profileId).toEqual([]); + } + expect(getProfileActionBindings("cakewalk_sonar", "midi.tool.range", "windows")).toEqual([]); + expect(getProfileActionBindings("logic_pro", "edit.nudgeLeftFine", "macos")).toEqual([]); + expect(getProfileActionBindings("fl_studio", "view.drumEditor", "windows")).toEqual([]); + expect(getProfileActionBindings("cubase", "edit.muteClips", "macos")).toEqual([]); + expect(getProfileActionBindings("studio_one", "edit.muteClips", "windows")).toEqual([]); + expect(getProfileActionBindings("renoise", "transport.record", "windows")).toEqual([]); + }); + + it("uses strict fallback semantics for customizable or focus-dependent source maps", () => { + for (const profileId of ["digital_performer", "waveform", "renoise"] as const) { + expect(getKeyboardShortcutProfile(profileId).fallbackPolicy).toBe("strict"); + expect(getProfileActionBindings(profileId, "file.quit", "windows"), profileId).toEqual([]); + expect(getProfileActionBindings(profileId, "midi.tool.draw", "macos"), profileId).toEqual([]); + expect(getProfileActionBindings(profileId, "edit.delete", "windows"), profileId).toEqual([]); + } + + expect(getProfileActionBindings("waveform", "transport.play", "windows")).toEqual(["Space"]); + expect(getProfileActionBindings("waveform", "edit.splitAtCursor", "macos")).toEqual(["/"]); + expect(getProfileActionBindings("renoise", "midi.toggleStepInput", "windows")).toEqual([]); + expect(getProfileActionBindings("openstudio", "file.quit", "windows")).toBeUndefined(); + }); + + it("exposes strict-policy and cross-platform-emulation labels to selectors, guides, and print", () => { + for (const profileId of ["digital_performer", "waveform", "renoise"] as const) { + const presentation = getKeyboardShortcutProfilePresentation(profileId, "windows"); + expect(presentation.policyLabel, profileId).toContain("Strict profile"); + expect(presentation.policyLabel, profileId).toContain("stay unassigned"); + expect(presentation.description, profileId).toContain(presentation.availabilityLabel); + } + + const logicOnWindows = getKeyboardShortcutProfilePresentation("logic_pro", "windows"); + expect(logicOnWindows.isNativeSourcePlatform).toBe(false); + expect(logicOnWindows.optionLabel).toBe("Logic Pro (cross-platform emulation)"); + expect(logicOnWindows.availabilityLabel).toContain("Cross-platform emulation on Windows"); + + const mixcraftOnWindows = getKeyboardShortcutProfilePresentation("mixcraft", "windows"); + expect(mixcraftOnWindows.isNativeSourcePlatform).toBe(true); + expect(mixcraftOnWindows.optionLabel).toBe("Mixcraft"); + }); + + it.each([ + ["logic_pro", "insert.marker"], + ["logic_pro", "navigate.nextTransient"], + ["logic_pro", "view.loadScreenset1"], + ["fl_studio", "view.toggleUndoHistory"], + ["fl_studio", "file.closeProject"], + ["fl_studio", "view.clipProperties"], + ["fl_studio", "view.setLoopToSelection"], + ["garageband", "view.toggleMixer"], + ["garageband", "insert.quickAddInstrument"], + ["garageband", "midi.panic"], + ["garageband", "file.openSafeMode"], + ["ardour", "help.contextualHelp"], + ["ardour", "view.clipProperties"], + ["ardour", "track.deleteSelected"], + ["adobe_audition", "transport.record"], + ["adobe_audition", "insert.regionFromSelection"], + ["mixcraft", "file.quit"], + ["mixcraft", "edit.duplicateClips"], + ["mixcraft", "insert.markerNamed"], + ] as const)("unbinds the native collision %s / %s", (profileId, actionId) => { + expect(getProfileActionBindings(profileId, actionId, "macos")).toEqual([]); + expect(getProfileActionBindings(profileId, actionId, "windows")).toEqual([]); + }); + + it("keeps only exact existing-action remaps for audited profiles", () => { + expect(getProfileActionBindings("logic_pro", "track.toggleSelectedMute", "macos")).toEqual(["M"]); + expect(getProfileActionBindings("logic_pro", "view.togglePianoRoll", "windows")).toEqual(["P"]); + expect(getProfileActionBindings("logic_pro", "file.closeProject", "macos")).toEqual(["Command+Option+W"]); + expect(getProfileActionBindings("fl_studio", "midi.tool.draw", "windows")).toEqual(["P"]); + expect(getProfileActionBindings("fl_studio", "midi.tool.erase", "macos")).toEqual(["D"]); + expect(getProfileActionBindings("fl_studio", "midi.duplicateSelection", "windows")).toEqual(["Ctrl+B"]); + expect(getProfileActionBindings("fl_studio", "midi.glueSelectedNotes", "macos")).toEqual(["Ctrl+G"]); + expect(getProfileActionBindings("fl_studio", "view.togglePianoRoll", "windows")).toEqual(["F7"]); + expect(getProfileActionBindings("garageband", "track.deleteSelected", "macos")).toEqual(["Ctrl+Delete"]); + expect(getProfileActionBindings("garageband", "midi.movePitchOctaveUp", "macos")).toEqual(["Option+Shift+Up"]); + expect(getProfileActionBindings("garageband", "midi.deselectAll", "windows")).toEqual(["Shift+D"]); + expect(getProfileActionBindings("ardour", "navigate.prevTransient", "windows")).toEqual(["Ctrl+Left"]); + expect(getProfileActionBindings("ardour", "view.loadScreenset1", "macos")).toEqual(["F1"]); + expect(getProfileActionBindings("mixcraft", "track.deleteSelected", "windows")).toEqual(["Ctrl+Shift+D"]); + expect(getProfileActionBindings("mixcraft", "track.moveSelectedDown", "windows")).toEqual(["Ctrl+D"]); + expect(getProfileActionBindings("mixcraft", "midi.selectNextNote", "macos")).toEqual(["Tab"]); + expect(getProfileActionBindings("mixcraft", "options.preferences", "macos")).toEqual(["Ctrl+Alt+P"]); + expect(getProfileActionBindings("adobe_audition", "view.verticalZoomIn", "macos")).toEqual(["Option+="]); + }); + + it("falls back safely for persisted unknown profile IDs", () => { + expect(getKeyboardShortcutProfile("removed-profile").id).toBe("openstudio"); + }); + + it("does not introduce same-scope effective shortcut collisions", () => { + const actions = getRegisteredActions(); + const conditionsOverlap = (left?: string, right?: string) => { + if (!left || left === "always" || !right || right === "always" || left === right) return true; + return !new Set([ + "step_input_disabled|step_input_enabled", + "step_input_enabled|step_input_disabled", + "transport_running|transport_stopped", + "transport_stopped|transport_running", + ]).has(`${left}|${right}`); + }; + + for (const platform of ["macos", "windows"] as const) { + for (const profile of KEYBOARD_SHORTCUT_PROFILES) { + const seen = new Map(); + for (const action of actions) { + const profileBindings = getProfileActionBindings(profile.id, action.id, platform); + const bindings = profileBindings ?? [action.shortcut, ...(action.shortcutAliases ?? [])] + .filter((binding): binding is string => typeof binding === "string" && !binding.includes("(")); + for (const scope of getActionShortcutScopes(action, profile.id)) { + for (const binding of bindings) { + const signature = shortcutBindingEventSignature(binding, platform); + expect(signature, `${profile.id}/${platform}: unreachable ${binding}`).toBeTruthy(); + if (!signature) continue; + const key = `${scope}:${signature}`; + const previous = seen.get(key); + if (previous && conditionsOverlap(previous.condition, action.shortcutWhen)) { + throw new Error(`${profile.id}/${platform} ${key}: ${previous.actionId} conflicts with ${action.id}`); + } + seen.set(key, { actionId: action.id, condition: action.shortcutWhen }); + } + } + } + } + } + }); + + it("keeps profile-global R Record reachable from every surface", () => { + const actions = getRegisteredActions(); + const recordProfiles = [ + "audacity", + "logic_pro", + "fl_studio", + "bitwig_studio", + "cakewalk_sonar", + "garageband", + ] as const; + for (const platform of ["macos", "windows"] as const) { + for (const profileId of recordProfiles) { + const recordBindings = getProfileActionBindings(profileId, "transport.record", platform) ?? []; + expect(recordBindings, `${profileId}/${platform}`).toContain("R"); + const shadows: string[] = []; + for (const action of actions) { + if (getActionShortcutScopes(action, profileId).includes("global")) continue; + const localBindings = getProfileActionBindings(profileId, action.id, platform) + ?? [action.shortcut, ...(action.shortcutAliases ?? [])] + .filter((binding): binding is string => typeof binding === "string" && !binding.includes("(")); + for (const recordBinding of recordBindings) { + const recordSignature = shortcutBindingEventSignature(recordBinding, platform); + if (recordSignature && localBindings.some( + (binding) => shortcutBindingEventSignature(binding, platform) === recordSignature, + )) { + shadows.push(action.id); + } + } + } + expect(shadows, `${profileId}/${platform}`).toEqual([]); + } + } + }); +}); diff --git a/frontend/src/__tests__/shortcutRegistry.test.ts b/frontend/src/__tests__/shortcutRegistry.test.ts index f26470c..0fed0eb 100644 --- a/frontend/src/__tests__/shortcutRegistry.test.ts +++ b/frontend/src/__tests__/shortcutRegistry.test.ts @@ -8,7 +8,7 @@ import { import { useDAWStore } from "../store/useDAWStore"; afterEach(() => { - useDAWStore.setState({ customShortcuts: {} }); + useDAWStore.setState({ customShortcuts: {}, keyboardShortcutProfileId: "openstudio" }); }); describe("shortcut registry", () => { @@ -41,6 +41,47 @@ describe("shortcut registry", () => { expect(getEffectiveActionShortcut("view.toggleMixer")).toBe("Ctrl+Shift+M"); }); + it("exposes the metronome as a profileable global action", () => { + const metronomeAction = getRegisteredAction("transport.metronome"); + expect(metronomeAction).toMatchObject({ + name: "Toggle Metronome", + shortcut: "K", + shortcutScope: "global", + }); + }); + + it("exposes pause-in-place for custom and imported profiles", () => { + expect(getRegisteredAction("transport.pause")).toMatchObject({ + name: "Pause in Place", + shortcutScope: "global", + shortcutWhen: "transport_running", + }); + }); + + it("uses the selected profile between custom overrides and registry defaults", () => { + expect(getActionShortcut("transport.rewind")).toBe("Home"); + + useDAWStore.setState({ + keyboardShortcutProfileId: "reaper", + customShortcuts: {}, + }); + expect(getEffectiveActionShortcut("transport.rewind")).toBe("W"); + + useDAWStore.setState({ customShortcuts: { "transport.rewind": "Ctrl+Home" } }); + expect(getEffectiveActionShortcut("transport.rewind")).toBe("Ctrl+Home"); + }); + + it("preserves an intentional profile unbind instead of displaying the factory shortcut", () => { + expect(getActionShortcut("tools.splitTool")).toBe("B"); + + useDAWStore.setState({ + keyboardShortcutProfileId: "pro_tools", + customShortcuts: {}, + }); + + expect(getEffectiveActionShortcut("tools.splitTool")).toBe(""); + }); + it("has no duplicate global shortcut assignments", () => { expect(getGlobalShortcutConflicts()).toEqual([]); }); diff --git a/frontend/src/__tests__/sliderProfileWheelIntegration.test.ts b/frontend/src/__tests__/sliderProfileWheelIntegration.test.ts new file mode 100644 index 0000000..4c7a20e --- /dev/null +++ b/frontend/src/__tests__/sliderProfileWheelIntegration.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getSliderWheelSubtarget } from "../components/ui/Slider/Slider"; +import { useDAWStore } from "../store/useDAWStore"; +import { resolveProfiledParameterWheel } from "../utils/parameterWheel"; +import { getShortcutPlatform } from "../utils/platform"; +import sliderSource from "../components/ui/Slider/Slider.tsx?raw"; +import knobSource from "../components/ui/Knob/Knob.tsx?raw"; +import { + beginEditTransaction, + commitEditTransaction, + createEditTransactionLifecycle, +} from "../components/ui/editTransactionLifecycle"; + +const originalMouseProfile = useDAWStore.getState().mouseBehaviorProfileId; + +function hostPrimaryModifier(): { ctrlKey: true } | { metaKey: true } { + return getShortcutPlatform() === "macos" ? { metaKey: true } : { ctrlKey: true }; +} + +afterEach(() => { + useDAWStore.setState({ mouseBehaviorProfileId: originalMouseProfile }); +}); + +describe("profiled Slider wheel routing", () => { + it("captures an edit's original commit and ends cancel/lost-capture races exactly once", () => { + const events: string[] = []; + const lifecycle = createEditTransactionLifecycle(); + expect(beginEditTransaction( + lifecycle, + () => events.push("begin"), + () => events.push("original commit"), + )).toBe(true); + expect(beginEditTransaction( + lifecycle, + () => events.push("duplicate begin"), + () => events.push("replacement commit"), + )).toBe(false); + expect(commitEditTransaction(lifecycle)).toBe(true); + expect(commitEditTransaction(lifecycle)).toBe(false); + expect(events).toEqual(["begin", "original commit"]); + }); + + it("captures the commit callback at wheel begin and keeps cleanup identity stable", () => { + for (const source of [sliderSource, knobSource]) { + expect(source).toContain("const wheelCommitCallbackRef = useRef"); + expect(source).toContain("wheelCommitCallbackRef.current = onCommitEdit"); + expect(source).toContain("const commitWheelEdit = useCallback(() => {"); + expect(source).toContain("}, []);"); + expect(source).toContain("createWheelDeltaAccumulator({"); + expect(source).toContain("onReset: () => commitWheelEditRef.current()"); + expect(source).toContain("accumulator.dispose()"); + expect(source).not.toContain("wheelCommitTimerRef"); + } + expect(sliderSource).toContain("onKeyDown={handleTransactionalKeyDown}"); + expect(sliderSource).not.toContain("onKeyDown={onKeyDown}"); + }); + + it("routes pointer drags, cancellation, resets, and cleanup through transactions", () => { + expect(sliderSource).toContain("const applyDiscreteValue = useCallback"); + expect(sliderSource).toContain("applyDiscreteValue(defaultValue)"); + expect(sliderSource).toContain("beginEditTransaction(pointerEditRef.current, onBeginEdit, onCommitEdit)"); + expect(sliderSource).toContain("onPointerCancel={handlePanPointerEnd}"); + expect(sliderSource).toContain("onLostPointerCapture={handlePanPointerEnd}"); + expect(sliderSource).toContain("useEffect(() => () => commitPointerEdit(), [commitPointerEdit])"); + expect(sliderSource).not.toContain("document.addEventListener('mouseup'"); + + expect(knobSource).toContain("beginEditTransaction(dragEditRef.current, onBeginEdit, onCommitEdit)"); + expect(knobSource).toContain("onPointerCancel={handlePointerCancel}"); + expect(knobSource).toContain("onLostPointerCapture={handlePointerCancel}"); + expect(knobSource).toContain("useEffect(() => () => commitDragEdit(), [commitDragEdit])"); + }); + + it("opts only vertical faders into the console-fader hit target", () => { + expect(getSliderWheelSubtarget("vertical", "fader")).toBe("console_fader"); + expect(getSliderWheelSubtarget("horizontal", "fader")).toBe("control"); + expect(getSliderWheelSubtarget("vertical", "default")).toBe("control"); + expect(getSliderWheelSubtarget("horizontal", "pan")).toBe("control"); + }); + + it("makes Cakewalk plain/fine fader adjustment reachable and suppresses unsafe group edits", () => { + useDAWStore.setState({ mouseBehaviorProfileId: "cakewalk_sonar" }); + const subtarget = getSliderWheelSubtarget("vertical", "fader"); + const resolve = (modifiers: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) => ( + resolveProfiledParameterWheel({ ...modifiers, deltaY: -120 }, subtarget) + ); + + expect(resolve({})).toMatchObject({ + ruleId: "cakewalk-sonar.console-fader", + operation: "adjust", + precision: "normal", + }); + expect(resolve({ shiftKey: true })).toMatchObject({ + ruleId: "cakewalk-sonar.console-fader-fine", + operation: "adjust", + precision: "fine", + }); + expect(resolve(hostPrimaryModifier())).toMatchObject({ + ruleId: "cakewalk-sonar.console-all-faders", + operation: "suppress", + }); + expect(resolve({ ...hostPrimaryModifier(), shiftKey: true })).toMatchObject({ + ruleId: "cakewalk-sonar.console-selected-faders", + operation: "suppress", + }); + }); +}); diff --git a/frontend/src/__tests__/takeExplodeImplodeSemantics.test.ts b/frontend/src/__tests__/takeExplodeImplodeSemantics.test.ts new file mode 100644 index 0000000..1876e48 --- /dev/null +++ b/frontend/src/__tests__/takeExplodeImplodeSemantics.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { + executeAvailableRegisteredAction, + getRegisteredAction, +} from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function audioClip(id: string, overrides: Partial = {}): AudioClip { + return { + id, + filePath: `C:/takes/${id}.wav`, + name: id, + startTime: 1, + duration: 2, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + gainEnvelope: [{ time: 0.25, gain: 0.75 }], + ...overrides, + }; +} + +beforeEach(() => { + commandManager.clear(); + vi.spyOn(nativeBridge, "addTrack").mockImplementation(async (id) => id || "generated-track"); + vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + vi.spyOn(nativeBridge, "reorderTrack").mockResolvedValue(true); + useDAWStore.setState((state) => ({ + tracks: [], + selectedClipId: null, + selectedClipIds: [], + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + globalLocked: false, + lockSettings: { ...state.lockSettings, items: false }, + syncClipsWithBackend: vi.fn(async () => undefined), + canUndo: false, + canRedo: false, + })); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("take explode/implode transactions", () => { + it("explodes nested takes to stable new tracks with one undo and exact backend replay", async () => { + const nested = audioClip("nested", { + startTime: 99, + gainEnvelope: [{ time: 0.5, gain: 0.4 }], + }); + const take = audioClip("take", { + startTime: 50, + gainEnvelope: [{ time: 0.25, gain: 0.6 }], + takes: [nested], + }); + const source = audioClip("source", { startTime: 7, takes: [take], activeTakeIndex: 0 }); + const track = createDefaultTrack("source-track", "Vocals", "#123456", "audio", []); + track.clips = [source]; + useDAWStore.setState({ + tracks: [track], + selectedClipId: source.id, + selectedClipIds: [source.id], + selectedTrackId: track.id, + selectedTrackIds: [track.id], + lastSelectedTrackId: track.id, + }); + + const action = getRegisteredAction("edit.explodeTakes")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + + await vi.waitFor(() => expect(useDAWStore.getState().tracks).toHaveLength(3)); + + let state = useDAWStore.getState(); + expect(state.tracks).toHaveLength(3); + expect(state.tracks[0].clips[0].takes).toBeUndefined(); + expect(state.tracks.slice(1).map((entry) => entry.type)).toEqual(["audio", "audio"]); + expect(state.tracks.slice(1).map((entry) => entry.clips[0].startTime)).toEqual([7, 7]); + expect(state.tracks.slice(1).map((entry) => entry.clips[0].filePath)).toEqual([ + take.filePath, + nested.filePath, + ]); + expect(state.tracks.slice(1).every((entry) => !entry.clips[0].takes)).toBe(true); + expect(state.tracks[1].clips[0].id).not.toBe(take.id); + expect(state.tracks[1].clips[0].gainEnvelope).not.toBe(take.gainEnvelope); + expect(commandManager.getUndoStack()).toHaveLength(1); + const explodedTrackIds = state.tracks.slice(1).map((entry) => entry.id); + const explodedClipIds = state.tracks.slice(1).map((entry) => entry.clips[0].id); + + take.gainEnvelope![0].gain = 9; + expect(useDAWStore.getState().tracks[1].clips[0].gainEnvelope![0].gain).toBe(0.6); + + const sync = state.syncClipsWithBackend as ReturnType; + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(1)); + expect(nativeBridge.addTrack).toHaveBeenCalledTimes(2); + expect(nativeBridge.reorderTrack).toHaveBeenCalledTimes(2); + + state.undo(); + state = useDAWStore.getState(); + expect(state.tracks).toHaveLength(1); + expect(state.tracks[0].clips[0].takes?.map((entry) => entry.id)).toEqual(["take"]); + expect(state.tracks[0].clips[0].takes?.[0].takes?.[0].id).toBe("nested"); + expect(state.selectedClipIds).toEqual([source.id]); + expect(commandManager.getUndoStack()).toHaveLength(0); + await vi.waitFor(() => expect(nativeBridge.removeTrack).toHaveBeenCalledTimes(2)); + expect(sync).toHaveBeenCalledTimes(2); + + state.redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks).toHaveLength(3)); + state = useDAWStore.getState(); + expect(state.tracks.slice(1).map((entry) => entry.id)).toEqual(explodedTrackIds); + expect(state.tracks.slice(1).map((entry) => entry.clips[0].id)).toEqual(explodedClipIds); + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(3)); + expect(nativeBridge.addTrack).toHaveBeenCalledTimes(4); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("rolls back a partial native track failure without frontend state or history", async () => { + const source = audioClip("source", { + takes: [audioClip("take-one"), audioClip("take-two")], + }); + const track = createDefaultTrack("source-track", "Vocals", "#123456", "audio", []); + track.clips = [source]; + useDAWStore.setState({ + tracks: [track], + selectedClipId: source.id, + selectedClipIds: [source.id], + }); + vi.mocked(nativeBridge.addTrack) + .mockImplementationOnce(async (id) => id || "generated-track") + .mockRejectedValueOnce(new Error("second native track failed")); + + await useDAWStore.getState().explodeTakes(source.id); + + const state = useDAWStore.getState(); + expect(state.tracks).toHaveLength(1); + expect(state.tracks[0].clips[0].takes?.map((take) => take.id)).toEqual([ + "take-one", + "take-two", + ]); + expect(nativeBridge.removeTrack).toHaveBeenCalledTimes(1); + expect(nativeBridge.removeTrack).toHaveBeenCalledWith( + vi.mocked(nativeBridge.addTrack).mock.calls[0][0], + ); + expect(nativeBridge.reorderTrack).not.toHaveBeenCalled(); + expect((state.syncClipsWithBackend as ReturnType)).not.toHaveBeenCalled(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("removes provisioned tracks and restores the source when playback sync fails", async () => { + const source = audioClip("source", { takes: [audioClip("take")] }); + const track = createDefaultTrack("source-track", "Vocals", "#123456", "audio", []); + track.clips = [source]; + const sync = vi.fn() + .mockRejectedValueOnce(new Error("clip sync failed")) + .mockResolvedValueOnce(undefined); + useDAWStore.setState({ + tracks: [track], + selectedClipId: source.id, + selectedClipIds: [source.id], + syncClipsWithBackend: sync, + }); + + await useDAWStore.getState().explodeTakes(source.id); + + expect(useDAWStore.getState().tracks).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].clips[0].takes?.[0].id).toBe("take"); + expect(nativeBridge.addTrack).toHaveBeenCalledTimes(1); + expect(nativeBridge.removeTrack).toHaveBeenCalledTimes(1); + expect(sync).toHaveBeenCalledTimes(2); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("implodes eligible clips without losing existing/nested takes and replays one backend command", async () => { + const mainExisting = audioClip("main-existing", { + gainEnvelope: [{ time: 0.1, gain: 0.2 }], + }); + const main = audioClip("main", { takes: [mainExisting], activeTakeIndex: 0 }); + const secondaryNested = audioClip("secondary-nested", { + gainEnvelope: [{ time: 0.2, gain: 0.3 }], + }); + const secondary = audioClip("secondary", { + startTime: 4, + takes: [secondaryNested], + gainEnvelope: [{ time: 0.3, gain: 0.4 }], + }); + const locked = audioClip("locked", { startTime: 8, locked: true }); + const firstTrack = createDefaultTrack("first", "First", "#111111", "audio", []); + const secondTrack = createDefaultTrack("second", "Second", "#222222", "audio", []); + const lockedTrack = createDefaultTrack("locked-track", "Locked", "#333333", "audio", []); + firstTrack.clips = [main]; + secondTrack.clips = [secondary]; + lockedTrack.clips = [locked]; + useDAWStore.setState({ + tracks: [firstTrack, secondTrack, lockedTrack], + selectedClipId: secondary.id, + selectedClipIds: [main.id, secondary.id, locked.id], + }); + + const action = getRegisteredAction("edit.implodeTakes")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + + let state = useDAWStore.getState(); + const result = state.tracks[0].clips[0]; + expect(result.takes?.map((take) => take.id)).toEqual([ + "main-existing", + "secondary", + "secondary-nested", + ]); + expect(state.tracks[1].clips).toEqual([]); + expect(state.tracks[2].clips.map((clip) => clip.id)).toEqual([locked.id]); + expect(state.selectedClipId).toBe(main.id); + expect(state.selectedClipIds).toEqual([main.id, locked.id]); + expect(result.takes?.[1].gainEnvelope).not.toBe(secondary.gainEnvelope); + expect(commandManager.getUndoStack()).toHaveLength(1); + const takeIds = result.takes!.map((take) => take.id); + + secondary.gainEnvelope![0].gain = 8; + expect(useDAWStore.getState().tracks[0].clips[0].takes?.[1].gainEnvelope?.[0].gain).toBe(0.4); + + const sync = state.syncClipsWithBackend as ReturnType; + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(1)); + expect(nativeBridge.addTrack).not.toHaveBeenCalled(); + expect(nativeBridge.removeTrack).not.toHaveBeenCalled(); + + state.undo(); + state = useDAWStore.getState(); + expect(state.tracks[1].clips.map((clip) => clip.id)).toEqual([secondary.id]); + expect(state.tracks[0].clips[0].takes?.map((take) => take.id)).toEqual([mainExisting.id]); + expect(state.selectedClipId).toBe(secondary.id); + expect(state.selectedClipIds).toEqual([main.id, secondary.id, locked.id]); + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2)); + + state.redo(); + state = useDAWStore.getState(); + expect(state.tracks[0].clips[0].takes?.map((take) => take.id)).toEqual(takeIds); + expect(state.tracks[1].clips).toEqual([]); + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(3)); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("claims but does not mutate when take actions are ineligible or locked", () => { + const source = audioClip("source", { takes: [audioClip("take")] }); + const second = audioClip("second", { startTime: 5 }); + const track = createDefaultTrack("track", "Track", "#111111", "audio", []); + track.clips = [source, second]; + useDAWStore.setState({ + tracks: [track], + selectedClipId: source.id, + selectedClipIds: [source.id, second.id], + }); + const explode = getRegisteredAction("edit.explodeTakes")!; + const implode = getRegisteredAction("edit.implodeTakes")!; + + expect(explode.canHandleShortcut?.()).toBe(false); + expect(executeAvailableRegisteredAction(explode.id)).toBe("claimed_noop"); + expect(implode.canHandleShortcut?.()).toBe(true); + + useDAWStore.setState((state) => ({ + globalLocked: true, + lockSettings: { ...state.lockSettings, items: false }, + })); + expect(implode.canHandleShortcut?.()).toBe(false); + expect(executeAvailableRegisteredAction(implode.id)).toBe("claimed_noop"); + useDAWStore.getState().implodeTakes([source.id, second.id]); + + useDAWStore.setState((state) => ({ + globalLocked: false, + lockSettings: { ...state.lockSettings, items: true }, + })); + useDAWStore.getState().explodeTakes(source.id); + + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, items: false }, + tracks: state.tracks.map((candidate) => candidate.id === track.id + ? { + ...candidate, + frozen: true, + clips: candidate.clips.map((clip) => clip.id === second.id + ? { ...clip, locked: true } + : clip), + } + : candidate), + selectedClipId: source.id, + selectedClipIds: [source.id], + })); + expect(explode.canHandleShortcut?.()).toBe(false); + useDAWStore.getState().explodeTakes(source.id); + useDAWStore.setState({ selectedClipIds: [source.id, second.id] }); + expect(implode.canHandleShortcut?.()).toBe(false); + useDAWStore.getState().implodeTakes([source.id, second.id]); + + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual([ + source.id, + second.id, + ]); + expect(nativeBridge.addTrack).not.toHaveBeenCalled(); + expect((useDAWStore.getState().syncClipsWithBackend as ReturnType)).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/tempoMarkerActionSemantics.test.ts b/frontend/src/__tests__/tempoMarkerActionSemantics.test.ts new file mode 100644 index 0000000..d440fad --- /dev/null +++ b/frontend/src/__tests__/tempoMarkerActionSemantics.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { useDAWStore } from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +beforeEach(() => { + commandManager.clear(); + vi.spyOn(nativeBridge, "setTempoMarkers").mockResolvedValue(true); + vi.spyOn(nativeBridge, "clearTempoMarkers").mockResolvedValue(true); + useDAWStore.setState({ + tempoMarkers: [], + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + canUndo: false, + canRedo: false, + }); +}); +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("tempo marker transactions", () => { + it("adds, updates, removes, undoes, and redoes stable IDs with exact backend sync", () => { + useDAWStore.getState().addTempoMarker(-2, 500); + let state = useDAWStore.getState(); + const markerId = state.tempoMarkers[0].id; + expect(state.tempoMarkers).toEqual([{ id: markerId, time: 0, tempo: 300 }]); + expect(nativeBridge.setTempoMarkers).toHaveBeenLastCalledWith([ + { id: markerId, time: 0, tempo: 300 }, + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + + state.undo(); + expect(useDAWStore.getState().tempoMarkers).toEqual([]); + expect(nativeBridge.clearTempoMarkers).toHaveBeenCalled(); + state = useDAWStore.getState(); + state.redo(); + expect(useDAWStore.getState().tempoMarkers[0].id).toBe(markerId); + + commandManager.clear(); + useDAWStore.getState().updateTempoMarker(markerId, { time: 4, tempo: 5 }); + expect(useDAWStore.getState().tempoMarkers).toEqual([ + { id: markerId, time: 4, tempo: 10 }, + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tempoMarkers[0]).toEqual({ id: markerId, time: 0, tempo: 300 }); + + commandManager.clear(); + useDAWStore.getState().removeTempoMarker(markerId); + expect(useDAWStore.getState().tempoMarkers).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(1); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tempoMarkers[0].id).toBe(markerId); + }); + + it("rejects invalid, missing, unchanged, and locked mutations without sync or history", () => { + useDAWStore.getState().addTempoMarker(Number.NaN, 120); + useDAWStore.getState().addTempoMarker(1, Number.POSITIVE_INFINITY); + useDAWStore.getState().removeTempoMarker("missing"); + expect(useDAWStore.getState().tempoMarkers).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(nativeBridge.setTempoMarkers).not.toHaveBeenCalled(); + + useDAWStore.getState().addTempoMarker(1, 120); + const marker = useDAWStore.getState().tempoMarkers[0]; + commandManager.clear(); + vi.mocked(nativeBridge.setTempoMarkers).mockClear(); + useDAWStore.getState().updateTempoMarker(marker.id, { time: 1, tempo: 120 }); + useDAWStore.getState().updateTempoMarker(marker.id, { time: Number.NaN }); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(nativeBridge.setTempoMarkers).not.toHaveBeenCalled(); + + useDAWStore.setState({ globalLocked: true }); + useDAWStore.getState().removeTempoMarker(marker.id); + useDAWStore.getState().updateTempoMarker(marker.id, { tempo: 140 }); + useDAWStore.getState().addTempoMarker(2, 140); + expect(useDAWStore.getState().tempoMarkers).toEqual([marker]); + + useDAWStore.setState({ + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: true }, + }); + useDAWStore.getState().removeTempoMarker(marker.id); + expect(useDAWStore.getState().tempoMarkers).toEqual([marker]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/timelineClipContextMenuIntegration.test.ts b/frontend/src/__tests__/timelineClipContextMenuIntegration.test.ts new file mode 100644 index 0000000..093d733 --- /dev/null +++ b/frontend/src/__tests__/timelineClipContextMenuIntegration.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import timelineSource from "../components/Timeline.tsx?raw"; + +function sourceBetween(start: string, end: string): string { + const startIndex = timelineSource.indexOf(start); + const endIndex = timelineSource.indexOf(end, startIndex + start.length); + expect(startIndex).toBeGreaterThanOrEqual(0); + expect(endIndex).toBeGreaterThan(startIndex); + return timelineSource.slice(startIndex, endIndex); +} + +describe("timeline clip context-menu integration", () => { + it("rejects secondary mouse buttons before either clip tool can act", () => { + const guard = "if ((e.evt?.button ?? 0) !== 0) return;"; + const splitCheck = 'if (toolModeRef.current === "split")'; + const audioMouseDown = sourceBetween( + "const handleMouseDown = (e: KonvaEvent) => {", + "const handleDragMoveModified = (e: KonvaEvent) => {", + ); + const midiMouseDown = sourceBetween( + "const handleMIDIClipMouseDown = (e: KonvaEvent) => {", + "const handleMIDIClipClick = (e: KonvaEvent) => {", + ); + + for (const handler of [audioMouseDown, midiMouseDown]) { + expect(handler).toContain(guard); + expect(handler.indexOf(guard)).toBeLessThan(handler.indexOf(splitCheck)); + } + }); + + it("rejects synthesized secondary-button clicks before clip or stage actions", () => { + const guard = "if ((e.evt?.button ?? 0) !== 0) return;"; + const audioClick = sourceBetween( + "const handleClipClick = (e: KonvaEvent) => {", + "const handleDragStart =", + ); + const midiClick = sourceBetween( + "const handleMIDIClipClick = (e: KonvaEvent) => {", + "const handleMIDIClipDoubleClick =", + ); + const mainStagePrelude = sourceBetween( + "{/* Main Timeline Stage */}", + "onContextMenu={(e: KonvaEvent) => {", + ); + + expect(audioClick.indexOf(guard)).toBeLessThan( + audioClick.indexOf("resolveLiveMouseModifierAction"), + ); + expect(midiClick.indexOf(guard)).toBeLessThan(midiClick.indexOf("selectClip")); + expect(mainStagePrelude.match(/if \(\(e\.evt\?\.button \?\? 0\) !== 0\) return;/g)).toHaveLength(2); + }); + + it("routes modified clip clicks through the selected semantic profile action", () => { + const audioClick = sourceBetween( + "const handleClipClick = (e: KonvaEvent) => {", + "const handleDragStart = (e: KonvaEvent) => {", + ); + const audioMouseDown = sourceBetween( + "const handleMouseDown = (e: KonvaEvent) => {", + "// Modified drag move to handle resize", + ); + + expect(audioClick).toContain('resolveLiveMouseModifierAction(e.evt || {}, "clip_drag")'); + expect(audioMouseDown).toContain('modifierAction === "constrain"'); + expect(audioMouseDown).toContain("axisLockRequested:"); + expect(timelineSource).not.toContain("suppressShiftGainClickRef"); + }); + + it("routes all clip context entry points through shared selection and snapped-time capture", () => { + expect(timelineSource).toContain("shouldPreserveClipContextSelection(selection"); + expect(timelineSource).toContain("selectedClipIds: [options.clipId]"); + expect(timelineSource).toContain("selectedTrackId: null"); + expect(timelineSource).toContain("selectedTrackIds: []"); + expect(timelineSource.match(/openTimelineClipContextMenu\(\{/g)).toHaveLength(3); + expect(timelineSource).toContain( + "time: resolveTimelinePointerSplitTime(options.stageX, options.ctrlBypass)", + ); + expect(timelineSource).toContain("if (isSnapActive(ctrlBypass))"); + expect(timelineSource).toContain("splitTime = snapTimelineTime(splitTime, splitTime)"); + }); + + it("offers split here and split at the current playhead as submenu choices", () => { + const menuSource = sourceBetween( + "const buildClipContextMenuItems =", + 'className="timeline-container', + ); + + expect(menuSource).toContain('label: "Split"'); + expect(menuSource).toContain('label: "Here"'); + expect(menuSource).toContain('label: "At Playhead"'); + expect(menuSource).toContain('shortcut: shortcut("edit.splitAtCursor", "S")'); + expect(menuSource).toContain("st.splitMIDIClipAtPosition(menu.clipId, splitTime)"); + expect(menuSource).toContain("st.splitClipAtPosition(menu.clipId, splitTime)"); + expect(menuSource).toContain("useDAWStore.getState().splitClipAtPlayhead()"); + expect(menuSource).not.toContain('label: "Split at Cursor"'); + }); +}); diff --git a/frontend/src/__tests__/timelineClipContextSelection.test.ts b/frontend/src/__tests__/timelineClipContextSelection.test.ts new file mode 100644 index 0000000..9d188b6 --- /dev/null +++ b/frontend/src/__tests__/timelineClipContextSelection.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { shouldPreserveClipContextSelection } from "../utils/timelineClipContextSelection"; + +describe("timeline clip context-menu selection", () => { + it("preserves a multi-clip selection for a selected member", () => { + expect(shouldPreserveClipContextSelection({ + selectedClipIds: ["one", "two"], + selectedTrackIds: [], + }, "two", "track-b")).toBe(true); + }); + + it("preserves selected tracks when no clips are selected", () => { + expect(shouldPreserveClipContextSelection({ + selectedClipIds: [], + selectedTrackIds: ["track-a", "track-b"], + }, "unselected-clip", "track-b")).toBe(true); + }); + + it("selects an anchor outside the active clip or track scope", () => { + expect(shouldPreserveClipContextSelection({ + selectedClipIds: ["one", "two"], + selectedTrackIds: ["track-a", "track-b"], + }, "outside", "track-c")).toBe(false); + }); + + it("does not let a selected track override an active clip selection", () => { + expect(shouldPreserveClipContextSelection({ + selectedClipIds: ["one"], + selectedTrackIds: ["track-b"], + }, "outside", "track-b")).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/timelineClipEdgeSnap.test.ts b/frontend/src/__tests__/timelineClipEdgeSnap.test.ts new file mode 100644 index 0000000..f41ebe0 --- /dev/null +++ b/frontend/src/__tests__/timelineClipEdgeSnap.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; +import { + clampTimelineClipGroupDelta, + isTimelineClipEdgeSnapMode, + resolveTimelineClipEdgeSnap, + snapshotTimelineClipGeometry, + shouldBypassTimelineDragSnap, + shouldStartTimelineCopyDrag, + type TimelineClipSnapShape, +} from "../utils/timelineClipEdgeSnap"; + +const viewport = { + startTime: 0, + endTime: 20, + visibleTrackIndices: [0, 1, 2], +}; + +const shape = ( + clipId: string, + startTime: number, + duration: number, + trackIndex = 0, +): TimelineClipSnapShape => ({ clipId, startTime, duration, trackIndex }); + +describe("timeline clip edge snapping", () => { + it("snapshots both audio and MIDI geometry at drag start", () => { + const audioClip = { id: "audio", startTime: 1, duration: 2, locked: false }; + const midiClip = { id: "midi", startTime: 4, duration: 3, locked: true }; + const snapshot = snapshotTimelineClipGeometry([{ + clips: [audioClip], + midiClips: [midiClip], + }]); + + audioClip.startTime = 99; + midiClip.duration = 99; + + expect(snapshot).toEqual([ + shape("audio", 1, 2), + shape("midi", 4, 3), + ]); + }); + + it("treats Ctrl as copy rather than snap bypass once copy-drag is established", () => { + expect(shouldBypassTimelineDragSnap(true, true)).toBe(false); + expect(shouldBypassTimelineDragSnap(true, false)).toBe(true); + expect(shouldBypassTimelineDragSnap(false, true)).toBe(false); + expect(shouldBypassTimelineDragSnap(false, false)).toBe(false); + }); + + it("starts copy-drag only for an unlocked move gesture", () => { + expect(shouldStartTimelineCopyDrag(true, "move", false)).toBe(true); + expect(shouldStartTimelineCopyDrag(false, "move", false)).toBe(false); + expect(shouldStartTimelineCopyDrag(true, "resize-left", false)).toBe(false); + expect(shouldStartTimelineCopyDrag(true, "resize-right", false)).toBe(false); + expect(shouldStartTimelineCopyDrag(true, "move", true)).toBe(false); + }); + + it.each([ + ["events", true], + ["events_cursor", true], + ["events_grid_cursor", true], + ["shuffle", true], + ["grid", false], + ["grid_relative", false], + ["cursor", false], + ["grid_cursor", false], + ] as const)("enables clip edges for %s only when event snapping is active", (mode, expected) => { + expect(isTimelineClipEdgeSnapMode(mode)).toBe(expected); + }); + + it.each([ + { + name: "start to start", + moving: shape("moving", 2.92, 1), + target: shape("target", 3, 2), + movingEdge: "start", + targetEdge: "start", + }, + { + name: "start to end", + moving: shape("moving", 4.92, 1), + target: shape("target", 3, 2), + movingEdge: "start", + targetEdge: "end", + }, + { + name: "end to start", + moving: shape("moving", 1.92, 1), + target: shape("target", 3, 2), + movingEdge: "end", + targetEdge: "start", + }, + { + name: "end to end", + moving: shape("moving", 3.92, 1), + target: shape("target", 3, 2), + movingEdge: "end", + targetEdge: "end", + }, + ])("supports $name", ({ moving, target, movingEdge, targetEdge }) => { + const result = resolveTimelineClipEdgeSnap({ + movingClips: [moving], + stationaryClips: [target], + rawDeltaTime: 0, + trackDelta: 0, + viewport, + pixelsPerSecond: 100, + }); + + expect(result.match).toMatchObject({ movingEdge, targetEdge }); + expect(result.match?.distancePx).toBeCloseTo(8); + expect(result.deltaTime).toBeCloseTo(0.08); + }); + + it("uses an inclusive 10 CSS-pixel threshold at any zoom", () => { + const atThreshold = resolveTimelineClipEdgeSnap({ + movingClips: [shape("moving", 1, 1)], + stationaryClips: [shape("target", 2.1, 1)], + rawDeltaTime: 0, + trackDelta: 0, + viewport, + pixelsPerSecond: 100, + }); + const outsideThreshold = resolveTimelineClipEdgeSnap({ + movingClips: [shape("moving", 1, 1)], + stationaryClips: [shape("target", 2.051, 1)], + rawDeltaTime: 0, + trackDelta: 0, + viewport, + pixelsPerSecond: 200, + }); + + expect(atThreshold.match?.distancePx).toBeCloseTo(10); + expect(outsideThreshold.match).toBeNull(); + }); + + it("uses only the individual moving and stationary edges visible in the viewport", () => { + const result = resolveTimelineClipEdgeSnap({ + movingClips: [shape("moving", 0, 9.94)], + stationaryClips: [shape("target", 1, 9)], + rawDeltaTime: 0, + trackDelta: 0, + viewport: { ...viewport, startTime: 5, endTime: 10 }, + pixelsPerSecond: 100, + }); + + expect(result.match).toMatchObject({ + movingEdge: "end", + targetEdge: "end", + targetTime: 10, + }); + expect(result.deltaTime).toBeCloseTo(0.06); + }); + + it("does not use a clip whose body spans the viewport but both edges are hidden", () => { + const result = resolveTimelineClipEdgeSnap({ + movingClips: [shape("moving", 1, 1)], + stationaryClips: [shape("spanning", -2, 10)], + rawDeltaTime: 0, + trackDelta: 0, + viewport: { ...viewport, startTime: 0, endTime: 5 }, + pixelsPerSecond: 100, + }); + + expect(result.match).toBeNull(); + }); + + it("requires both moving destination and stationary source tracks to be vertically visible", () => { + const hiddenMoving = resolveTimelineClipEdgeSnap({ + movingClips: [shape("moving", 1.95, 1, 0)], + stationaryClips: [shape("target", 3, 1, 1)], + rawDeltaTime: 0, + trackDelta: 2, + viewport: { ...viewport, visibleTrackIndices: [0, 1] }, + pixelsPerSecond: 100, + }); + const hiddenTarget = resolveTimelineClipEdgeSnap({ + movingClips: [shape("moving", 1.95, 1, 0)], + stationaryClips: [shape("target", 3, 1, 2)], + rawDeltaTime: 0, + trackDelta: 0, + viewport: { ...viewport, visibleTrackIndices: [0, 1] }, + pixelsPerSecond: 100, + }); + + expect(hiddenMoving.match).toBeNull(); + expect(hiddenTarget.match).toBeNull(); + }); + + it("excludes actual moving IDs but can include the source for copy previews", () => { + const options = { + movingClips: [shape("source", 2.95, 1)], + stationaryClips: [shape("source", 2, 1)], + rawDeltaTime: 0, + trackDelta: 0, + viewport, + pixelsPerSecond: 100, + }; + + expect(resolveTimelineClipEdgeSnap(options).match).toBeNull(); + expect(resolveTimelineClipEdgeSnap({ + ...options, + excludeMovingClipIds: false, + }).match).toMatchObject({ + movingEdge: "start", + targetEdge: "end", + }); + }); + + it("keeps nonmoving selected clips eligible as stationary targets", () => { + const result = resolveTimelineClipEdgeSnap({ + movingClips: [shape("moving-unlocked", 1.94, 1)], + stationaryClips: [ + shape("moving-unlocked", 1, 1), + shape("selected-but-locked", 3, 1), + ], + rawDeltaTime: 0, + trackDelta: 0, + viewport, + pixelsPerSecond: 100, + }); + + expect(result.match).toMatchObject({ + movingClipId: "moving-unlocked", + targetClipId: "selected-but-locked", + movingEdge: "end", + targetEdge: "start", + }); + }); + + it("lets a non-anchor edge drive one shared multi-clip delta", () => { + const result = resolveTimelineClipEdgeSnap({ + movingClips: [ + shape("anchor", 1, 1, 0), + shape("midi-peer", 6, 2, 1), + ], + stationaryClips: [shape("target", 8.08, 1, 2)], + rawDeltaTime: 0, + trackDelta: 0, + viewport, + pixelsPerSecond: 100, + }); + + expect(result.match).toMatchObject({ + movingClipId: "midi-peer", + movingEdge: "end", + targetEdge: "start", + }); + expect(result.deltaTime).toBeCloseTo(0.08); + expect(1 + result.deltaTime).toBeCloseTo(1.08); + expect(6 + result.deltaTime).toBeCloseTo(6.08); + }); + + it("clamps the group once at time zero and rejects impossible snap corrections", () => { + const moving = [shape("earliest", 0.25, 1), shape("anchor", 2, 1)]; + expect(clampTimelineClipGroupDelta(moving, -1)).toBe(-0.25); + + const result = resolveTimelineClipEdgeSnap({ + movingClips: moving, + stationaryClips: [shape("target", 1.7, 1)], + rawDeltaTime: -0.2, + trackDelta: 0, + viewport, + pixelsPerSecond: 100, + }); + + expect(result.match).toBeNull(); + expect(result.deltaTime).toBeCloseTo(-0.2); + }); + + it("breaks equal-distance ties deterministically", () => { + const result = resolveTimelineClipEdgeSnap({ + movingClips: [shape("moving", 2, 1)], + stationaryClips: [ + shape("later", 3.05, 1), + shape("earlier", 2.95, 1), + ], + rawDeltaTime: 0, + trackDelta: 0, + viewport, + pixelsPerSecond: 100, + }); + + expect(result.match).toMatchObject({ targetClipId: "earlier", targetTime: 2.95 }); + expect(result.deltaTime).toBeCloseTo(-0.05); + }); +}); diff --git a/frontend/src/__tests__/timelineClipGestureLock.test.ts b/frontend/src/__tests__/timelineClipGestureLock.test.ts new file mode 100644 index 0000000..3c574c0 --- /dev/null +++ b/frontend/src/__tests__/timelineClipGestureLock.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import timelineSource from "../components/Timeline.tsx?raw"; +import { + isTimelineClipGestureLocked, + runTimelineClipGestureMutation, + type TimelineClipGestureLockState, +} from "../utils/clipEditLock"; + +function stateWithClips(): TimelineClipGestureLockState { + return { + globalLocked: false, + lockSettings: { items: false }, + tracks: [{ + clips: [{ id: "audio-1", locked: false }], + midiClips: [{ id: "midi-1", locked: false }], + }], + }; +} + +describe("timeline pointer gesture lock authority", () => { + it.each([ + ["global lock", (state: TimelineClipGestureLockState) => { state.globalLocked = true; }, "audio-1"], + ["item lock", (state: TimelineClipGestureLockState) => { state.lockSettings = { items: true }; }, "midi-1"], + ["audio clip lock", (state: TimelineClipGestureLockState) => { state.tracks[0].clips[0].locked = true; }, "audio-1"], + ["MIDI clip lock", (state: TimelineClipGestureLockState) => { state.tracks[0].midiClips[0].locked = true; }, "midi-1"], + ])("blocks %s before preview, undo, or backend work", (_label, lock, clipId) => { + const state = stateWithClips(); + lock(state); + const preview = vi.fn(); + const commandPush = vi.fn(); + const backendSync = vi.fn(); + const restore = vi.fn(); + + const handled = runTimelineClipGestureMutation( + state, + [clipId], + () => { + preview(); + commandPush(); + backendSync(); + }, + restore, + ); + + expect(handled).toBe(false); + expect(restore).toHaveBeenCalledOnce(); + expect(preview).not.toHaveBeenCalled(); + expect(commandPush).not.toHaveBeenCalled(); + expect(backendSync).not.toHaveBeenCalled(); + }); + + it("cancels a multi-clip gesture if any participating clip becomes locked", () => { + const state = stateWithClips(); + expect(isTimelineClipGestureLocked(state, ["audio-1", "midi-1"])).toBe(false); + + state.tracks[0].midiClips[0].locked = true; + + expect(isTimelineClipGestureLocked(state, ["audio-1", "midi-1"])).toBe(true); + }); + + it("treats a deleted/replaced gesture target as locked", () => { + expect(isTimelineClipGestureLocked(stateWithClips(), ["missing-clip"])).toBe(true); + }); + + it("restores the exact pre-gesture geometry when a lock engages mid-preview", () => { + const state = stateWithClips(); + const before = { startTime: 2, duration: 4, offset: 0.5, isModified: false }; + let live = { ...before }; + + expect(runTimelineClipGestureMutation(state, ["audio-1"], () => { + live = { startTime: 7, duration: 1.5, offset: 1.25, isModified: true }; + })).toBe(true); + + state.lockSettings = { items: true }; + const undoPush = vi.fn(); + const backendSync = vi.fn(); + expect(runTimelineClipGestureMutation( + state, + ["audio-1"], + () => { + undoPush(); + backendSync(); + }, + () => { live = { ...before }; }, + )).toBe(false); + + expect(live).toEqual(before); + expect(undoPush).not.toHaveBeenCalled(); + expect(backendSync).not.toHaveBeenCalled(); + }); + + it("wires current-state gates into every Timeline pointer lifecycle", () => { + expect(timelineSource).toContain("useDAWStore.subscribe((state) => {"); + expect(timelineSource).toContain("getTimelineGestureClipIds(activeGesture)"); + expect(timelineSource).toContain("cancelActiveTimelineClipGesture();"); + expect(timelineSource).toContain("cancelClipVolumeEdit(volumeGesture.clipId)"); + expect(timelineSource).toContain("restoreTimelineGestureUndo();"); + expect(timelineSource).toContain("runTimelineClipGestureMutation("); + expect(timelineSource.match(/draggable=\{!clipEditLocked\}/g)).toHaveLength(6); + expect(timelineSource).not.toContain("draggable={!clip.locked}"); + }); +}); diff --git a/frontend/src/__tests__/timelineClipStretch.test.ts b/frontend/src/__tests__/timelineClipStretch.test.ts new file mode 100644 index 0000000..d1ea2fe --- /dev/null +++ b/frontend/src/__tests__/timelineClipStretch.test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; +import { + computeTimelineStretchGeometry, + createStretchedMIDIClip, +} from "../utils/timelineClipStretch"; + +const initialState = useDAWStore.getState(); + +function audioClip(overrides: Partial = {}): AudioClip { + return { + id: "audio-stretch", + filePath: "C:/audio/current.wav", + pitchCorrectionSourceFilePath: "C:/audio/pre-pitch.wav", + pitchCorrectionSourceOffset: 3, + name: "Audio", + startTime: 4, + duration: 6, + offset: 2, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 1, + fadeOut: 0.5, + sampleRate: 48000, + sourceLength: 12, + gainEnvelope: [{ time: 1.5, gain: 0.75 }], + ...overrides, + }; +} + +function midiClip(overrides: Partial = {}): MIDIClip { + return { + id: "midi-stretch", + name: "MIDI", + startTime: 2, + duration: 8, + offset: 1, + sourceStart: 0.5, + sourceLength: 8, + loopEnabled: true, + loopOffset: 2, + loopLength: 4, + events: [ + { timestamp: 1, type: "noteOn", note: 60, velocity: 100 }, + { timestamp: 3, type: "noteOff", note: 60, velocity: 0 }, + ], + ccEvents: [{ time: 2, cc: 1, value: 64 }], + quantizeBackup: { + events: [{ timestamp: 1.25, type: "noteOn", note: 62, velocity: 90 }], + ccEvents: [{ time: 2.5, cc: 11, value: 100 }], + }, + color: "#f72585", + ...overrides, + }; +} + +describe("timeline stretch geometry", () => { + it("keeps the left edge fixed for a right-edge stretch and scales source offset", () => { + expect(computeTimelineStretchGeometry({ + kind: "resize-right", + originalStartTime: 4, + originalDuration: 6, + originalOffset: 3, + deltaTime: 3, + minDuration: 0.1, + })).toEqual({ + startTime: 4, + duration: 9, + offset: 4.5, + timeScale: 1.5, + playbackRateRatio: 2 / 3, + }); + }); + + it("keeps the right edge fixed for a left-edge stretch", () => { + expect(computeTimelineStretchGeometry({ + kind: "resize-left", + originalStartTime: 4, + originalDuration: 6, + originalOffset: 3, + deltaTime: -2, + })).toEqual({ + startTime: 2, + duration: 8, + offset: 4, + timeScale: 4 / 3, + playbackRateRatio: 0.75, + }); + }); + + it("snaps the dragged edge and clamps against the timeline/minimum duration", () => { + expect(computeTimelineStretchGeometry({ + kind: "resize-left", + originalStartTime: 0.25, + originalDuration: 2, + originalOffset: 0, + deltaTime: -10, + snapTime: Math.round, + }).startTime).toBe(0); + expect(computeTimelineStretchGeometry({ + kind: "resize-right", + originalStartTime: 4, + originalDuration: 2, + originalOffset: 0, + deltaTime: -20, + minDuration: 0.1, + }).duration).toBeCloseTo(0.1); + }); +}); + +describe("MIDI time-domain scaling", () => { + it("scales events, controllers, loops, source fields, and quantize backups", () => { + const stretched = createStretchedMIDIClip(midiClip(), 6, 4); + expect(stretched).toMatchObject({ + startTime: 6, + duration: 4, + offset: 0.5, + sourceStart: 0.25, + sourceLength: 4, + loopOffset: 1, + loopLength: 2, + }); + expect(stretched.events.map((event) => event.timestamp)).toEqual([0.5, 1.5]); + expect(stretched.ccEvents?.[0].time).toBe(1); + expect(stretched.quantizeBackup?.events[0].timestamp).toBe(0.625); + expect(stretched.quantizeBackup?.ccEvents?.[0].time).toBe(1.25); + }); +}); + +describe("undo-safe clip stretching", () => { + beforeEach(() => { + commandManager.clear(); + useDAWStore.setState(initialState); + vi.spyOn(nativeBridge, "timeStretchClip").mockResolvedValue({ + success: true, + filePath: "C:/audio/current_ts.wav", + duration: 24, + sampleRate: 96000, + }); + vi.spyOn(nativeBridge, "refreshWaveformPeaks").mockResolvedValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(initialState); + }); + + it("processes audio exactly once, commits one command, and reuses the result for redo", async () => { + const track = createDefaultTrack("audio-track", "Audio", "#38bdf8", "audio"); + track.clips = [audioClip()]; + const syncClipsWithBackend = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ tracks: [track], syncClipsWithBackend, canUndo: false, canRedo: false }); + + await expect(useDAWStore.getState().stretchClip("audio-stretch", 2, 12)).resolves.toBe(true); + + expect(nativeBridge.timeStretchClip).toHaveBeenCalledTimes(1); + expect(nativeBridge.timeStretchClip).toHaveBeenCalledWith("C:/audio/current.wav", 0.5); + expect(nativeBridge.refreshWaveformPeaks).toHaveBeenCalledWith("C:/audio/current_ts.wav"); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ + filePath: "C:/audio/current_ts.wav", + originalFilePath: "C:/audio/current.wav", + playbackRate: 0.5, + startTime: 2, + duration: 12, + offset: 4, + sourceLength: 24, + fadeIn: 2, + fadeOut: 1, + sampleRate: 96000, + pitchCorrectionSourceFilePath: undefined, + pitchCorrectionSourceOffset: undefined, + }); + expect(useDAWStore.getState().tracks[0].clips[0].gainEnvelope?.[0].time).toBe(3); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0]).toEqual(audioClip()); + + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips[0].filePath).toBe("C:/audio/current_ts.wav"); + expect(nativeBridge.timeStretchClip).toHaveBeenCalledTimes(1); + expect(syncClipsWithBackend).toHaveBeenCalledTimes(3); + }); + + it("leaves state and history unchanged when audio processing fails", async () => { + vi.mocked(nativeBridge.timeStretchClip).mockResolvedValue({ success: false }); + const track = createDefaultTrack("audio-track", "Audio", "#38bdf8", "audio"); + track.clips = [audioClip()]; + useDAWStore.setState({ tracks: [track], canUndo: false, canRedo: false }); + + await expect(useDAWStore.getState().stretchClip("audio-stretch", 4, 10)).resolves.toBe(false); + + expect(useDAWStore.getState().tracks[0].clips[0]).toEqual(audioClip()); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("does not overwrite a clip changed while audio rendering is in flight", async () => { + let finishStretch!: (value: { success: true; filePath: string; duration: number }) => void; + vi.mocked(nativeBridge.timeStretchClip).mockImplementation(() => new Promise((resolve) => { + finishStretch = resolve; + })); + const track = createDefaultTrack("audio-track", "Audio", "#38bdf8", "audio"); + track.clips = [audioClip()]; + useDAWStore.setState({ tracks: [track], canUndo: false, canRedo: false }); + + const pending = useDAWStore.getState().stretchClip("audio-stretch", 4, 12); + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((candidate) => ({ + ...candidate, + clips: candidate.clips.map((clip) => clip.id === "audio-stretch" + ? { ...clip, volumeDB: -6 } + : clip), + })), + })); + finishStretch({ success: true, filePath: "C:/audio/orphaned-render.wav", duration: 24 }); + + await expect(pending).resolves.toBe(false); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ + filePath: "C:/audio/current.wav", + duration: 6, + volumeDB: -6, + }); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(nativeBridge.refreshWaveformPeaks).not.toHaveBeenCalled(); + }); + + it("stretches MIDI without audio processing and restores all timing on undo", async () => { + const track = createDefaultTrack("midi-track", "MIDI", "#f72585", "midi"); + track.midiClips = [midiClip()]; + const syncMIDITrackToBackend = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ tracks: [track], syncMIDITrackToBackend, canUndo: false, canRedo: false }); + + await expect(useDAWStore.getState().stretchClip("midi-stretch", 4, 4)).resolves.toBe(true); + + expect(nativeBridge.timeStretchClip).not.toHaveBeenCalled(); + expect(useDAWStore.getState().tracks[0].midiClips[0].events.map((event) => event.timestamp)) + .toEqual([0.5, 1.5]); + expect(syncMIDITrackToBackend).toHaveBeenCalledWith("midi-track", { debounce: false }); + expect(commandManager.getUndoStack()).toHaveLength(1); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].midiClips[0]).toEqual(midiClip()); + }); +}); diff --git a/frontend/src/__tests__/timelineClipboardPasteSemantics.test.ts b/frontend/src/__tests__/timelineClipboardPasteSemantics.test.ts new file mode 100644 index 0000000..3e3b41f --- /dev/null +++ b/frontend/src/__tests__/timelineClipboardPasteSemantics.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function audioClip(id: string, overrides: Partial = {}): AudioClip { + return { + id, + filePath: `C:/audio/${id}.wav`, + name: id, + startTime: 2, + duration: 2, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + gainEnvelope: [{ time: 0.5, gain: 0.75 }], + ...overrides, + }; +} + +function midiClip(id: string): MIDIClip { + return { + id, + name: id, + startTime: 4, + duration: 2, + offset: 0, + sourceLength: 2, + loopLength: 2, + events: [ + { timestamp: 0.25, type: "noteOn", note: 60, velocity: 100 }, + { timestamp: 1.25, type: "noteOff", note: 60, velocity: 0 }, + ], + ccEvents: [{ cc: 1, time: 0.5, value: 64 }], + color: "#f72585", + }; +} + +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState((state) => ({ + tracks: [], + selectedClipId: null, + selectedClipIds: [], + selectedTrackId: null, + selectedTrackIds: [], + clipboard: { clip: null, clips: [], isCut: false }, + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + moveEnvelopesWithItems: true, + transport: { ...state.transport, currentTime: 0 }, + syncClipsWithBackend: vi.fn(async () => undefined), + syncMIDITrackToBackend: vi.fn(async () => undefined), + canUndo: false, + canRedo: false, + })); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("deep timeline clipboard and paste entry points", () => { + it("cuts immediately, then context/menu paste moves deep audio and automation atomically", () => { + const track = createDefaultTrack("audio", "Audio", "#38bdf8", "audio", []); + const source = audioClip("source", { + takes: [audioClip("take", { startTime: 0 })], + }); + track.clips = [source]; + track.automationLanes = [{ + id: "lane", + param: "volume", + points: [{ id: "point", time: 2.5, value: 0.5 }], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }]; + useDAWStore.setState({ + tracks: [track], + selectedClipId: source.id, + selectedClipIds: [source.id], + }); + + useDAWStore.getState().cutSelectedClips(); + let state = useDAWStore.getState(); + expect(state.tracks[0].clips).toEqual([]); + expect(state.tracks[0].automationLanes[0].points).toEqual([]); + expect(state.clipboard).toMatchObject({ isCut: true, sourceRemoved: true }); + expect(state.clipboard.clip?.id).toBe(source.id); + expect(commandManager.getUndoStack()).toHaveLength(1); + + // pasteClip is the explicit target/time path used by EditMenu and both + // Timeline context menus. It now delegates to the same deep paste planner. + state.pasteClip("audio", 6); + state = useDAWStore.getState(); + const pasted = state.tracks[0].clips[0]; + const pastedId = pasted.id; + expect(pasted).toMatchObject({ startTime: 6, duration: 2 }); + expect(pasted.id).not.toBe(source.id); + expect(pasted.gainEnvelope).not.toBe((state.clipboard.clip as AudioClip | null)?.gainEnvelope); + expect(pasted.takes?.[0].id).not.toBe(source.takes?.[0].id); + expect(state.tracks[0].automationLanes[0].points) + .toEqual([{ id: "point", time: 6.5, value: 0.5 }]); + expect(state.clipboard.clip).toBeNull(); + expect(commandManager.getUndoStack()).toHaveLength(2); + + state.undo(); + expect(useDAWStore.getState().tracks[0].clips).toEqual([]); + expect(useDAWStore.getState().clipboard).toMatchObject({ isCut: true, sourceRemoved: true }); + state = useDAWStore.getState(); + state.undo(); + expect(useDAWStore.getState().tracks[0].clips[0].id).toBe(source.id); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points[0].id).toBe("point"); + + state = useDAWStore.getState(); + state.redo(); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips[0].id).toBe(pastedId); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points[0].id).toBe("point"); + }); + + it("claims but safely no-ops for explicit incompatible, frozen, and locked paste targets", () => { + const audio = createDefaultTrack("audio", "Audio", "#38bdf8", "audio", []); + const midi = createDefaultTrack("midi", "MIDI", "#f72585", "midi", []); + const source = audioClip("source"); + useDAWStore.setState({ + tracks: [audio, midi], + clipboard: { + clip: source, + clips: [{ clip: source, trackId: "audio" }], + isCut: false, + }, + }); + + useDAWStore.getState().pasteClip("midi", 3); + expect(useDAWStore.getState().tracks[1].midiClips).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.setState((state) => ({ + globalLocked: true, + selectedTrackIds: ["audio"], + transport: { ...state.transport, currentTime: 3 }, + })); + expect(getRegisteredAction("edit.paste")!.canHandleShortcut?.()).toBe(false); + useDAWStore.getState().pasteClip("audio", 3); + expect(useDAWStore.getState().tracks[0].clips).toEqual([]); + + useDAWStore.setState((state) => ({ + globalLocked: false, + tracks: state.tracks.map((track) => track.id === "audio" ? { ...track, frozen: true } : track), + })); + useDAWStore.getState().pasteClip("audio", 3); + expect(useDAWStore.getState().tracks[0].clips).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("smart-pastes mixed audio/MIDI entries only into compatible selected tracks", () => { + const targetAudio = createDefaultTrack("target-audio", "Audio", "#38bdf8", "audio", []); + const targetMIDI = createDefaultTrack("target-midi", "MIDI", "#f72585", "instrument", []); + const audio = audioClip("audio-source", { startTime: 1 }); + const midi = midiClip("midi-source"); + useDAWStore.setState((state) => ({ + tracks: [targetAudio, targetMIDI], + selectedTrackId: "target-midi", + selectedTrackIds: ["target-audio", "target-midi"], + transport: { ...state.transport, currentTime: 10 }, + clipboard: { + clip: audio, + clips: [ + { clip: audio, trackId: "source-audio" }, + { clip: midi, trackId: "source-midi" }, + ], + isCut: false, + }, + })); + + const action = getRegisteredAction("edit.paste")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + const state = useDAWStore.getState(); + expect(state.tracks[0].clips).toHaveLength(1); + expect(state.tracks[0].midiClips).toHaveLength(0); + expect(state.tracks[1].clips).toHaveLength(0); + expect(state.tracks[1].midiClips).toHaveLength(1); + expect(state.tracks[0].clips[0].startTime).toBe(10); + expect(state.tracks[1].midiClips[0].startTime).toBe(13); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); +}); diff --git a/frontend/src/__tests__/timelineDragAxisLock.test.ts b/frontend/src/__tests__/timelineDragAxisLock.test.ts index 4c47020..1344562 100644 --- a/frontend/src/__tests__/timelineDragAxisLock.test.ts +++ b/frontend/src/__tests__/timelineDragAxisLock.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { getTimelineAxisLockedDeltas, + resolveTimelineDropTrackIndex, resolveTimelineDragAxisLock, } from "../utils/timelineDragAxisLock"; +import { canApplyTimelineHorizontalSnap } from "../utils/timelineClipEdgeSnap"; describe("timeline drag axis lock", () => { it("does not lock or move before the drag threshold", () => { @@ -52,4 +54,25 @@ describe("timeline drag axis lock", () => { deltaY: 12, }); }); + + it("blocks horizontal snapping while Shift lock is pending or locked vertically", () => { + expect(canApplyTimelineHorizontalSnap(true, null)).toBe(false); + expect(canApplyTimelineHorizontalSnap(true, "y")).toBe(false); + expect(canApplyTimelineHorizontalSnap(true, "x")).toBe(true); + expect(canApplyTimelineHorizontalSnap(false, null)).toBe(true); + }); + + it("clamps a drag above the timeline to the first track", () => { + expect(resolveTimelineDropTrackIndex(-12, 300, 3, null)).toBe(0); + expect(resolveTimelineDropTrackIndex(-0.01, 300, 3, null)).toBe(0); + }); + + it("keeps normal hits and the below-track insertion target unchanged", () => { + expect(resolveTimelineDropTrackIndex(120, 300, 3, { + trackIndex: 1, + isInClipArea: true, + })).toBe(1); + expect(resolveTimelineDropTrackIndex(301, 300, 3, null)).toBe(3); + expect(resolveTimelineDropTrackIndex(-12, 0, 0, null)).toBe(0); + }); }); diff --git a/frontend/src/__tests__/timelineGestureUndo.test.ts b/frontend/src/__tests__/timelineGestureUndo.test.ts new file mode 100644 index 0000000..054f64d --- /dev/null +++ b/frontend/src/__tests__/timelineGestureUndo.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { CommandManager, commandManager } from "../store/commands"; +import { useDAWStore } from "../store/useDAWStore"; +import { + createTimelineGestureUndoCommand, + getTimelineTrackTopologyDelta, + reconcileTimelineTrackTopology, + type TimelineGestureSnapshot, +} from "../utils/timelineGestureUndo"; + +type StubTrack = { id: string; value: number }; + +function snapshot( + tracks: readonly StubTrack[], + isModified: boolean, +): TimelineGestureSnapshot { + return { + tracks, + selectedClipId: tracks.length > 0 ? tracks[tracks.length - 1].id : null, + selectedClipIds: tracks.map((track) => track.id), + isModified, + }; +} + +describe("timeline gesture undo command", () => { + it("owns one undo entry, restores dirty state, and reports topology in both directions", () => { + const before = snapshot([{ id: "existing", value: 1 }], false); + const after = snapshot([ + { id: "existing", value: 2 }, + { id: "generated", value: 3 }, + ], true); + let live = snapshot(after.tracks, after.isModified); + const topology: Array<{ added: string[]; removed: string[] }> = []; + const manager = new CommandManager(); + + manager.push(createTimelineGestureUndoCommand("Move timeline clip", before, after, { + cloneTracks: (tracks) => tracks.map((track) => ({ ...track })), + applySnapshot: (next) => { live = next; }, + afterApply: (previous, next) => { + const delta = getTimelineTrackTopologyDelta(previous.tracks, next.tracks); + topology.push({ + added: delta.added.map((track) => track.id), + removed: delta.removed.map((track) => track.id), + }); + }, + })); + + expect(manager.getUndoStack()).toHaveLength(1); + expect(manager.undo()).toBe(true); + expect(live).toMatchObject({ + tracks: [{ id: "existing", value: 1 }], + selectedClipId: "existing", + isModified: false, + }); + expect(topology).toEqual([{ added: [], removed: ["generated"] }]); + + expect(manager.redo()).toBe(true); + expect(live).toMatchObject({ + tracks: [ + { id: "existing", value: 2 }, + { id: "generated", value: 3 }, + ], + selectedClipId: "generated", + isModified: true, + }); + expect(topology[topology.length - 1]).toEqual({ added: ["generated"], removed: [] }); + }); + + it("adds before content sync, removes after it, and removes even when sync fails", async () => { + const existing = { id: "existing" }; + const generated = { id: "generated" }; + const redoOrder: string[] = []; + await reconcileTimelineTrackTopology([existing], [existing, generated], { + addTrack: async (track) => { redoOrder.push(`add:${track.id}`); }, + syncContent: async () => { redoOrder.push("sync"); }, + removeTrack: async (track) => { redoOrder.push(`remove:${track.id}`); }, + }); + expect(redoOrder).toEqual(["add:generated", "sync"]); + + const undoOrder: string[] = []; + await expect(reconcileTimelineTrackTopology([existing, generated], [existing], { + addTrack: async (track) => { undoOrder.push(`add:${track.id}`); }, + syncContent: async () => { + undoOrder.push("sync"); + throw new Error("clip sync failed"); + }, + removeTrack: async (track) => { undoOrder.push(`remove:${track.id}`); }, + })).rejects.toThrow("clip sync failed"); + expect(undoOrder).toEqual(["sync", "remove:generated"]); + }); +}); + +describe("compound timeline track insertion", () => { + const originalState = useDAWStore.getState(); + + beforeEach(() => { + commandManager.clear(); + useDAWStore.setState({ + tracks: [], + globalLocked: false, + canUndo: false, + canRedo: false, + }); + vi.spyOn(nativeBridge, "addTrack").mockResolvedValue("generated"); + vi.spyOn(nativeBridge, "setTrackType").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setTrackRecordArm").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setTrackInputMonitoring").mockResolvedValue(true); + vi.spyOn(nativeBridge, "setTrackInputChannels").mockResolvedValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); + }); + + it("can insert a backend-precreated track without adding a second undo command", () => { + useDAWStore.getState().addTrack({ + id: "generated", + name: "Audio 2", + type: "audio", + }, { + backendAlreadyCreated: true, + recordUndo: false, + }); + + expect(useDAWStore.getState().tracks.map((track) => track.id)).toEqual(["generated"]); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(nativeBridge.addTrack).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/timelineQuantizeSemantics.test.ts b/frontend/src/__tests__/timelineQuantizeSemantics.test.ts new file mode 100644 index 0000000..c01c74d --- /dev/null +++ b/frontend/src/__tests__/timelineQuantizeSemantics.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function audioClip(overrides: Partial = {}): AudioClip { + return { + id: "audio", + filePath: "C:/audio.wav", + name: "Audio", + startTime: -0.13, + duration: 1, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + ...overrides, + }; +} + +function midiClip(overrides: Partial = {}): MIDIClip { + return { + id: "midi", + name: "MIDI", + startTime: 0.26, + duration: 1, + events: [], + ccEvents: [], + color: "#f72585", + ...overrides, + }; +} + +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState((state) => ({ + tracks: [], + selectedClipId: null, + selectedClipIds: [], + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + moveEnvelopesWithItems: true, + transport: { ...state.transport, tempo: 120 }, + timeSignature: { numerator: 4, denominator: 4 }, + gridSize: "1/16", + quantizePresetId: "factory-1/16", + syncClipsWithBackend: vi.fn(async () => undefined), + syncMIDITrackToBackend: vi.fn(async () => undefined), + canUndo: false, + canRedo: false, + })); +}); +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("timeline quantize truthfulness", () => { + it("clamps negative starts and quantizes mixed audio/MIDI with stable automation and redo", () => { + const audio = createDefaultTrack("audio-track", "Audio", "#38bdf8", "audio", []); + audio.clips = [audioClip()]; + audio.automationLanes = [{ + id: "lane", + param: "volume", + points: [{ id: "point", time: 0.1, value: 0.5 }], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }]; + const midi = createDefaultTrack("midi-track", "MIDI", "#f72585", "midi", []); + midi.midiClips = [midiClip()]; + useDAWStore.setState({ + tracks: [audio, midi], + selectedClipId: "midi", + selectedClipIds: ["audio", "midi"], + }); + + const action = getRegisteredAction("edit.quantizeToGrid")!; + expect(action.canHandleShortcut?.()).toBe(true); + expect(useDAWStore.getState().quantizeSelectedClips()).toBe(true); + let state = useDAWStore.getState(); + expect(state.tracks[0].clips[0]).toMatchObject({ id: "audio", startTime: 0 }); + expect(state.tracks[1].midiClips[0]).toMatchObject({ id: "midi", startTime: 0.25 }); + expect(state.tracks[0].automationLanes[0].points[0]).toMatchObject({ + id: "point", + time: 0.23, + }); + expect(commandManager.getUndoStack()).toHaveLength(1); + + state.undo(); + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(-0.13); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points[0]) + .toMatchObject({ id: "point", time: 0.1 }); + state = useDAWStore.getState(); + state.redo(); + expect(useDAWStore.getState().tracks[0].clips[0]).toMatchObject({ id: "audio", startTime: 0 }); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points[0]) + .toMatchObject({ id: "point", time: 0.23 }); + }); + + it("honors global, item, clip, frozen-track, and envelope locks without false history", () => { + const audio = createDefaultTrack("audio-track", "Audio", "#38bdf8", "audio", []); + audio.clips = [audioClip({ startTime: 0.13 })]; + audio.automationLanes = [{ + id: "lane", + param: "volume", + points: [{ id: "point", time: 0.2, value: 0.5 }], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }]; + useDAWStore.setState({ + tracks: [audio], + selectedClipId: "audio", + selectedClipIds: ["audio"], + lockSettings: { items: false, envelopes: true, timeSelection: false, markers: false }, + }); + + expect(useDAWStore.getState().quantizeSelectedClips()).toBe(true); + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(0.125); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points[0].time).toBe(0.2); + useDAWStore.getState().undo(); + commandManager.clear(); + + useDAWStore.setState({ globalLocked: true, canUndo: false, canRedo: false }); + expect(getRegisteredAction("edit.quantizeToGrid")!.canHandleShortcut?.()).toBe(false); + expect(useDAWStore.getState().quantizeSelectedClips()).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.setState({ + globalLocked: false, + lockSettings: { items: true, envelopes: false, timeSelection: false, markers: false }, + }); + expect(useDAWStore.getState().quantizeSelectedClips()).toBe(false); + + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, items: false }, + tracks: state.tracks.map((track) => ({ + ...track, + clips: track.clips.map((clip) => ({ ...clip, locked: true })), + })), + })); + expect(useDAWStore.getState().quantizeSelectedClips()).toBe(false); + + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => ({ + ...track, + frozen: true, + clips: track.clips.map((clip) => ({ ...clip, locked: false })), + })), + })); + expect(useDAWStore.getState().quantizeSelectedClips()).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/timelineSlipEditing.test.ts b/frontend/src/__tests__/timelineSlipEditing.test.ts new file mode 100644 index 0000000..665a235 --- /dev/null +++ b/frontend/src/__tests__/timelineSlipEditing.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; +import { computeSlipOffset } from "../utils/timelineClipGestures"; + +const originalState = useDAWStore.getState(); + +function audioClip(): AudioClip { + return { + id: "audio-slip", + filePath: "C:/audio/slip.wav", + name: "Audio slip", + startTime: 0, + duration: 4, + offset: 1, + sourceLength: 12, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }; +} + +function midiClip(): MIDIClip { + return { + id: "midi-slip", + name: "MIDI slip", + startTime: 0, + duration: 4, + offset: 1, + sourceStart: 0, + sourceLength: 8, + loopEnabled: false, + loopOffset: 0, + loopLength: 8, + events: [], + ccEvents: [], + color: "#f72585", + }; +} + +beforeEach(() => { + commandManager.clear(); + const audio = createDefaultTrack("audio-track", "Audio", "#38bdf8", "audio", []); + audio.clips = [audioClip()]; + const midi = createDefaultTrack("midi-track", "MIDI", "#f72585", "midi", []); + midi.midiClips = [midiClip()]; + useDAWStore.setState({ + tracks: [audio, midi], + syncMIDITrackToBackend: vi.fn(async () => undefined), + isModified: false, + canUndo: false, + canRedo: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("timeline slip editing", () => { + it("moves in the expected direction and clamps to the source window", () => { + expect(computeSlipOffset(5, 2, 10)).toBe(3); + expect(computeSlipOffset(5, -2, 10)).toBe(7); + expect(computeSlipOffset(1, 10, 10)).toBe(0); + expect(computeSlipOffset(9, -10, 10)).toBe(10); + }); + + it("commits one undoable audio offset change and preserves no-op state", () => { + useDAWStore.getState().slipEditClip("audio-slip", 3); + expect(useDAWStore.getState().tracks[0].clips[0].offset).toBe(3); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(useDAWStore.getState().isModified).toBe(true); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks[0].clips[0].offset).toBe(1); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks[0].clips[0].offset).toBe(3); + + commandManager.clear(); + useDAWStore.setState({ canUndo: false, canRedo: false, isModified: false }); + useDAWStore.getState().slipEditClip("audio-slip", 3); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().isModified).toBe(false); + }); + + it("commits and synchronizes MIDI slip edits through undo and redo", async () => { + const sync = useDAWStore.getState().syncMIDITrackToBackend; + useDAWStore.getState().slipEditClip("midi-slip", 2.5); + await Promise.resolve(); + expect(useDAWStore.getState().tracks[1].midiClips[0].offset).toBe(2.5); + expect(sync).toHaveBeenLastCalledWith("midi-track", { debounce: false }); + + useDAWStore.getState().undo(); + await Promise.resolve(); + expect(useDAWStore.getState().tracks[1].midiClips[0].offset).toBe(1); + useDAWStore.getState().redo(); + await Promise.resolve(); + expect(useDAWStore.getState().tracks[1].midiClips[0].offset).toBe(2.5); + expect(sync).toHaveBeenCalledTimes(3); + }); +}); diff --git a/frontend/src/__tests__/timelineSplitTransaction.test.ts b/frontend/src/__tests__/timelineSplitTransaction.test.ts new file mode 100644 index 0000000..5b811a9 --- /dev/null +++ b/frontend/src/__tests__/timelineSplitTransaction.test.ts @@ -0,0 +1,856 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; + +const initialState = useDAWStore.getState(); + +function audioClip(overrides: Partial = {}): AudioClip { + return { + id: "audio-clip", + filePath: "C:/audio/shared.wav", + name: "Audio", + startTime: 2, + duration: 6, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + sampleRate: 48000, + sourceLength: 20, + ...overrides, + }; +} + +function midiClip(overrides: Partial = {}): MIDIClip { + return { + id: "midi-clip", + name: "MIDI", + startTime: 1, + duration: 6, + offset: 0, + sourceStart: 0, + sourceLength: 8, + loopEnabled: false, + loopOffset: 0, + loopLength: 8, + events: [ + { timestamp: 2, type: "noteOn", note: 60, velocity: 96 }, + { timestamp: 4, type: "noteOff", note: 60, velocity: 0 }, + ], + ccEvents: [{ time: 2.5, cc: 1, value: 64 }], + color: "#f72585", + ...overrides, + }; +} + +function setupTracks() { + const audioTrack = createDefaultTrack("track-audio", "Audio", "#38bdf8", "audio"); + const midiTrack = createDefaultTrack("track-midi", "MIDI", "#f72585", "midi"); + return { audioTrack, midiTrack }; +} + +describe("unified timeline split transaction", () => { + beforeEach(() => { + commandManager.clear(); + useDAWStore.setState(initialState); + }); + + afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(initialState); + }); + + it("splits selected audio and MIDI clips in place as one undoable transaction", () => { + const { audioTrack, midiTrack } = setupTracks(); + audioTrack.clips = [ + audioClip({ id: "audio-before", startTime: 0, duration: 1 }), + audioClip({ id: "audio-selected" }), + audioClip({ id: "audio-after", startTime: 10, duration: 1 }), + ]; + midiTrack.midiClips = [midiClip({ id: "midi-selected" })]; + const syncClipsWithBackend = vi.fn().mockResolvedValue(undefined); + const syncMIDITrackToBackend = vi.fn().mockResolvedValue(undefined); + + useDAWStore.setState({ + tracks: [audioTrack, midiTrack], + transport: { ...initialState.transport, currentTime: 4 }, + selectedClipIds: ["audio-selected", "midi-selected"], + selectedClipId: "midi-selected", + selectedTrackIds: ["track-audio", "track-midi"], + selectedTrackId: "track-midi", + lastSelectedTrackId: "track-audio", + syncClipsWithBackend, + syncMIDITrackToBackend, + canUndo: false, + canRedo: false, + }); + + useDAWStore.getState().splitClipAtPlayhead(); + + const splitState = useDAWStore.getState(); + expect(splitState.tracks[0].clips.map((clip) => clip.id)).toEqual([ + "audio-before", + expect.not.stringMatching(/^audio-/), + expect.not.stringMatching(/^audio-/), + "audio-after", + ]); + expect(splitState.tracks[0].clips.slice(1, 3).map((clip) => clip.startTime)).toEqual([2, 4]); + expect(splitState.tracks[1].midiClips.map((clip) => clip.startTime)).toEqual([1, 4]); + expect(splitState.selectedClipIds).toHaveLength(4); + expect(splitState.selectedClipIds).toEqual(expect.arrayContaining([ + splitState.tracks[0].clips[1].id, + splitState.tracks[0].clips[2].id, + splitState.tracks[1].midiClips[0].id, + splitState.tracks[1].midiClips[1].id, + ])); + expect(splitState.selectedClipId).toBe(splitState.tracks[1].midiClips[1].id); + expect(splitState.selectedTrackIds).toEqual(["track-audio", "track-midi"]); + expect(syncClipsWithBackend).toHaveBeenCalledTimes(1); + expect(syncMIDITrackToBackend).toHaveBeenCalledWith("track-midi", { debounce: false }); + expect(commandManager.getUndoStack()).toHaveLength(1); + + const splitAudioIds = splitState.tracks[0].clips.slice(1, 3).map((clip) => clip.id); + const splitMIDIIds = splitState.tracks[1].midiClips.map((clip) => clip.id); + useDAWStore.getState().undo(); + + const undone = useDAWStore.getState(); + expect(undone.tracks[0].clips.map((clip) => clip.id)).toEqual([ + "audio-before", + "audio-selected", + "audio-after", + ]); + expect(undone.tracks[1].midiClips.map((clip) => clip.id)).toEqual(["midi-selected"]); + expect(undone.selectedClipIds).toEqual(["audio-selected", "midi-selected"]); + expect(undone.selectedClipId).toBe("midi-selected"); + expect(undone.selectedTrackIds).toEqual(["track-audio", "track-midi"]); + expect(undone.selectedTrackId).toBe("track-midi"); + expect(undone.lastSelectedTrackId).toBe("track-audio"); + + useDAWStore.getState().redo(); + const redone = useDAWStore.getState(); + expect(redone.tracks[0].clips.slice(1, 3).map((clip) => clip.id)).toEqual(splitAudioIds); + expect(redone.tracks[1].midiClips.map((clip) => clip.id)).toEqual(splitMIDIIds); + }); + + it("uses selected tracks when no clips are selected, keeps clip selection empty, and skips locked clips", () => { + const { audioTrack, midiTrack } = setupTracks(); + const otherTrack = createDefaultTrack("track-other", "Other", "#94a3b8", "audio"); + audioTrack.clips = [ + audioClip({ id: "audio-anchor" }), + audioClip({ id: "audio-locked", startTime: 3, locked: true }), + ]; + midiTrack.midiClips = [midiClip({ id: "midi-track-selected" })]; + otherTrack.clips = [audioClip({ id: "audio-other" })]; + const syncClipsWithBackend = vi.fn().mockResolvedValue(undefined); + const syncMIDITrackToBackend = vi.fn().mockResolvedValue(undefined); + + useDAWStore.setState({ + tracks: [audioTrack, midiTrack, otherTrack], + selectedClipIds: [], + selectedClipId: null, + selectedTrackIds: ["track-audio", "track-midi"], + selectedTrackId: "track-audio", + lastSelectedTrackId: "track-midi", + syncClipsWithBackend, + syncMIDITrackToBackend, + canUndo: false, + canRedo: false, + }); + + useDAWStore.getState().splitClipAtPosition("audio-anchor", 4); + + const state = useDAWStore.getState(); + expect(state.tracks[0].clips).toHaveLength(3); + expect(state.tracks[0].clips.map((clip) => clip.id)).toContain("audio-locked"); + expect(state.tracks[1].midiClips).toHaveLength(2); + expect(state.tracks[2].clips.map((clip) => clip.id)).toEqual(["audio-other"]); + expect(state.selectedClipIds).toEqual([]); + expect(state.selectedClipId).toBeNull(); + expect(state.selectedTrackIds).toEqual(["track-audio", "track-midi"]); + expect(state.selectedTrackId).toBe("track-audio"); + expect(state.lastSelectedTrackId).toBe("track-midi"); + }); + + it("falls through non-crossing selected clips to clips crossing on selected tracks", () => { + const { audioTrack } = setupTracks(); + audioTrack.clips = [ + audioClip({ id: "selected-elsewhere", startTime: 0, duration: 1 }), + audioClip({ id: "track-crossing", startTime: 3, duration: 4 }), + ]; + useDAWStore.setState({ + tracks: [audioTrack], + transport: { ...initialState.transport, currentTime: 5 }, + selectedClipIds: ["selected-elsewhere"], + selectedClipId: "selected-elsewhere", + selectedTrackIds: ["track-audio"], + selectedTrackId: "track-audio", + syncClipsWithBackend: vi.fn().mockResolvedValue(undefined), + }); + + useDAWStore.getState().splitClipAtPlayhead(); + + const state = useDAWStore.getState(); + expect(state.tracks[0].clips.map((clip) => clip.id)).toContain("selected-elsewhere"); + expect(state.tracks[0].clips.filter((clip) => clip.id !== "selected-elsewhere")).toHaveLength(2); + expect(state.tracks[0].clips.map((clip) => clip.startTime)).toEqual([0, 3, 5]); + expect(state.selectedClipIds).toEqual(["selected-elsewhere"]); + }); + + it("splits every unlocked crossing clip at the playhead when nothing is selected", () => { + const { audioTrack, midiTrack } = setupTracks(); + audioTrack.clips = [ + audioClip({ id: "audio-crossing", startTime: 2, duration: 5 }), + audioClip({ id: "audio-outside", startTime: 8, duration: 2 }), + audioClip({ id: "audio-locked", startTime: 1, duration: 6, locked: true }), + ]; + midiTrack.midiClips = [midiClip({ id: "midi-crossing", startTime: 1, duration: 6 })]; + useDAWStore.setState({ + tracks: [audioTrack, midiTrack], + transport: { ...initialState.transport, currentTime: 4 }, + selectedClipIds: [], + selectedClipId: null, + selectedTrackIds: [], + selectedTrackId: null, + syncClipsWithBackend: vi.fn().mockResolvedValue(undefined), + syncMIDITrackToBackend: vi.fn().mockResolvedValue(undefined), + }); + + useDAWStore.getState().splitClipAtPlayhead(); + + const state = useDAWStore.getState(); + expect(state.tracks[0].clips.map((clip) => clip.startTime)).toEqual([2, 4, 8, 1]); + expect(state.tracks[0].clips.map((clip) => clip.id)).toEqual(expect.arrayContaining([ + "audio-outside", + "audio-locked", + ])); + expect(state.tracks[1].midiClips.map((clip) => clip.startTime)).toEqual([1, 4]); + expect(state.selectedClipIds).toEqual([]); + }); + + it("does not create history or sync for a locked clip or a boundary split", () => { + const { audioTrack } = setupTracks(); + audioTrack.clips = [audioClip({ id: "locked", locked: true })]; + const syncClipsWithBackend = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ + tracks: [audioTrack], + selectedClipIds: ["locked"], + selectedClipId: "locked", + syncClipsWithBackend, + canUndo: false, + canRedo: false, + }); + + useDAWStore.getState().splitClipAtPosition("locked", 4); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(syncClipsWithBackend).not.toHaveBeenCalled(); + + audioTrack.clips = [audioClip({ id: "boundary" })]; + useDAWStore.setState({ tracks: [audioTrack], selectedClipIds: ["boundary"], selectedClipId: "boundary" }); + useDAWStore.getState().splitClipAtPosition("boundary", 2); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual(["boundary"]); + + useDAWStore.getState().splitClipAtPosition("boundary", 8); + useDAWStore.getState().splitClipAtPosition("boundary", 2.0000005); + useDAWStore.getState().splitClipAtPosition("boundary", 7.9999995); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual(["boundary"]); + }); + + it("transforms source offsets, fades, gain envelopes, and alternate takes for both audio children", () => { + const { audioTrack } = setupTracks(); + audioTrack.clips = [audioClip({ + id: "detailed", + startTime: 2, + duration: 8, + offset: 1, + pitchCorrectionSourceFilePath: "C:/audio/original.wav", + pitchCorrectionSourceOffset: 5, + fadeIn: 4, + fadeOut: 6, + gainEnvelope: [ + { time: 0, gain: 0.5 }, + { time: 4, gain: 1.5 }, + { time: 8, gain: 1 }, + ], + takes: [audioClip({ + id: "take-one", + filePath: "C:/audio/take.wav", + startTime: 2, + duration: 8, + offset: 2, + pitchCorrectionSourceOffset: 10, + fadeIn: 2, + fadeOut: 3, + gainEnvelope: [{ time: 0, gain: 1 }, { time: 6, gain: 0.4 }], + takes: undefined, + activeTakeIndex: undefined, + })], + activeTakeIndex: 0, + })]; + const syncClipsWithBackend = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ + tracks: [audioTrack], + selectedClipIds: ["detailed"], + selectedClipId: "detailed", + syncClipsWithBackend, + }); + + useDAWStore.getState().splitClipAtPosition("detailed", 5); + + const [left, right] = useDAWStore.getState().tracks[0].clips; + expect(left).toMatchObject({ startTime: 2, duration: 3, offset: 1, fadeIn: 3, fadeOut: 0 }); + expect(right).toMatchObject({ + startTime: 5, + duration: 5, + offset: 4, + pitchCorrectionSourceOffset: 8, + fadeIn: 0, + fadeOut: 5, + }); + expect(left.gainEnvelope).toEqual([ + { time: 0, gain: 0.5 }, + { time: 3, gain: 1.25 }, + ]); + expect(right.gainEnvelope).toEqual([ + { time: 0, gain: 1.25 }, + { time: 1, gain: 1.5 }, + { time: 5, gain: 1 }, + ]); + expect(left.activeTakeIndex).toBe(0); + expect(right.activeTakeIndex).toBe(0); + expect(left.takes?.[0]).toMatchObject({ startTime: 2, duration: 3, offset: 2, fadeIn: 2, fadeOut: 0 }); + expect(right.takes?.[0]).toMatchObject({ + startTime: 5, + duration: 5, + offset: 5, + pitchCorrectionSourceOffset: 13, + fadeIn: 0, + fadeOut: 3, + }); + expect(left.takes?.[0].id).not.toBe("take-one"); + expect(right.takes?.[0].id).not.toBe("take-one"); + expect(left.takes?.[0].id).not.toBe(right.takes?.[0].id); + expect(Number.isFinite(left.takes?.[0].startTime)).toBe(true); + expect(Number.isFinite(right.takes?.[0].startTime)).toBe(true); + expect(useDAWStore.getState().selectedClipIds).toEqual([left.id, right.id]); + expect(useDAWStore.getState().selectedClipId).toBe(right.id); + }); + + it("bounds heterogeneous alternate takes to their own duration and source window", () => { + const { audioTrack } = setupTracks(); + audioTrack.clips = [audioClip({ + id: "heterogeneous", + startTime: 2, + duration: 8, + takes: [ + audioClip({ + id: "short-source-take", + filePath: "C:/audio/short.wav", + startTime: 2, + duration: 5, + offset: 2, + sourceLength: 6, + pitchCorrectionSourceOffset: 10, + fadeOut: 3, + takes: undefined, + activeTakeIndex: undefined, + }), + audioClip({ + id: "ends-before-split", + filePath: "C:/audio/shorter.wav", + startTime: 2, + duration: 2, + offset: 1, + sourceLength: 3, + pitchCorrectionSourceOffset: 20, + fadeOut: 1, + takes: undefined, + activeTakeIndex: undefined, + }), + ], + activeTakeIndex: 0, + })]; + useDAWStore.setState({ + tracks: [audioTrack], + selectedClipIds: ["heterogeneous"], + selectedClipId: "heterogeneous", + syncClipsWithBackend: vi.fn().mockResolvedValue(undefined), + }); + + useDAWStore.getState().splitClipAtPosition("heterogeneous", 5); + + const [left, right] = useDAWStore.getState().tracks[0].clips; + expect(left.takes?.[0]).toMatchObject({ duration: 3, offset: 2, fadeOut: 0 }); + expect(right.takes?.[0]).toMatchObject({ + duration: 1, + offset: 5, + pitchCorrectionSourceOffset: 13, + fadeOut: 1, + }); + expect((right.takes?.[0].offset || 0) + (right.takes?.[0].duration || 0)).toBe(6); + + expect(left.takes?.[1]).toMatchObject({ duration: 2, offset: 1, fadeOut: 1 }); + expect(right.takes?.[1]).toMatchObject({ + duration: 0, + offset: 3, + pitchCorrectionSourceOffset: 22, + fadeOut: 0, + }); + }); + + it("clones MIDI source content and keeps invalid MIDI editor sessions closed across undo", () => { + const { midiTrack } = setupTracks(); + midiTrack.midiClips = [midiClip({ id: "edited-midi", offset: 1 })]; + const session = { + sessionId: "session-midi", + trackId: "track-midi", + clipId: "edited-midi", + mode: "docked" as const, + selectedNoteIds: ["note-1"], + midiEditRange: null, + editCursorTime: 2, + activeTool: "select" as const, + visibleLanes: [{ id: "velocity", kind: "velocity" as const, label: "Velocity", height: 72 }], + activeLaneId: "velocity", + scrollY: 0, + windowPixelsPerSecond: 100, + windowScrollX: 0, + openedAt: 1, + updatedAt: 1, + }; + const syncMIDITrackToBackend = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ + tracks: [midiTrack], + selectedClipIds: ["edited-midi"], + selectedClipId: "edited-midi", + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + dockedMidiEditorSessionId: session.sessionId, + detachedPanels: ["midiEditor"], + showPianoRoll: true, + pianoRollTrackId: "track-midi", + pianoRollClipId: "edited-midi", + selectedNoteIds: ["note-1"], + pianoRollEditCursorTime: 2, + syncMIDITrackToBackend, + }); + + useDAWStore.getState().splitMIDIClipAtPosition("edited-midi", 4); + + const splitState = useDAWStore.getState(); + const [left, right] = splitState.tracks[0].midiClips; + expect(left.offset).toBe(1); + expect(right.offset).toBe(4); + expect(left.events).toEqual(right.events); + expect(left.events).not.toBe(right.events); + expect(left.events[0]).not.toBe(right.events[0]); + expect(left.events[0]).not.toBe(midiTrack.midiClips[0].events[0]); + expect(splitState.midiEditorSessions).toEqual([]); + expect(splitState.pianoRollClipId).toBeNull(); + expect(splitState.showPianoRoll).toBe(false); + expect(splitState.detachedPanels).not.toContain("midiEditor"); + + useDAWStore.getState().undo(); + const undone = useDAWStore.getState(); + expect(undone.tracks[0].midiClips.map((clip) => clip.id)).toEqual(["edited-midi"]); + expect(undone.midiEditorSessions).toEqual([]); + expect(undone.pianoRollClipId).toBeNull(); + expect(undone.showPianoRoll).toBe(false); + expect(undone.selectedNoteIds).toEqual([]); + }); + + it("closes a detached native MIDI editor window whose source clip is split", () => { + const { midiTrack } = setupTracks(); + midiTrack.midiClips = [midiClip({ id: "windowed-midi" })]; + const session = { + sessionId: "windowed-session", + trackId: "track-midi", + clipId: "windowed-midi", + mode: "windowed" as const, + selectedNoteIds: [], + midiEditRange: null, + editCursorTime: null, + activeTool: "select" as const, + visibleLanes: [], + activeLaneId: "velocity", + scrollY: 0, + windowPixelsPerSecond: 100, + windowScrollX: 0, + openedAt: 1, + updatedAt: 1, + }; + const closeMidiEditorWindow = vi.spyOn(nativeBridge, "closeMidiEditorWindow").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [midiTrack], + selectedClipIds: ["windowed-midi"], + selectedClipId: "windowed-midi", + midiEditorSessions: [session], + activeMidiEditorSessionId: session.sessionId, + dockedMidiEditorSessionId: null, + detachedPanels: ["midiEditor"], + showPianoRoll: false, + pianoRollTrackId: "track-midi", + pianoRollClipId: "windowed-midi", + syncMIDITrackToBackend: vi.fn().mockResolvedValue(undefined), + }); + + useDAWStore.getState().splitMIDIClipAtPosition("windowed-midi", 4); + + expect(closeMidiEditorWindow).toHaveBeenCalledWith("windowed-session", "sourceSplit"); + expect(useDAWStore.getState().midiEditorSessions).toEqual([]); + expect(useDAWStore.getState().detachedPanels).not.toContain("midiEditor"); + }); + + it("closes the pitch editor before removing its source clip ID", () => { + const { audioTrack } = setupTracks(); + audioTrack.clips = [audioClip({ id: "pitch-source" })]; + const closePitchEditor = vi.fn(() => useDAWStore.setState({ + showPitchEditor: false, + pitchEditorTrackId: null, + pitchEditorClipId: null, + pitchEditorFxIndex: 0, + })); + useDAWStore.setState({ + tracks: [audioTrack], + selectedClipIds: ["pitch-source"], + selectedClipId: "pitch-source", + showPitchEditor: true, + pitchEditorTrackId: "track-audio", + pitchEditorClipId: "pitch-source", + closePitchEditor, + syncClipsWithBackend: vi.fn().mockResolvedValue(undefined), + }); + + useDAWStore.getState().splitClipAtPosition("pitch-source", 4); + + expect(closePitchEditor).toHaveBeenCalledTimes(1); + expect(useDAWStore.getState().showPitchEditor).toBe(false); + expect(useDAWStore.getState().pitchEditorClipId).toBeNull(); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().showPitchEditor).toBe(false); + }); +}); + +describe("playback clip sync identity", () => { + beforeEach(async () => { + commandManager.clear(); + const { resetSyncCache } = await import("../store/actions/clips"); + await resetSyncCache(); + useDAWStore.setState(initialState); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + commandManager.clear(); + const { resetSyncCache } = await import("../store/actions/clips"); + await resetSyncCache(); + useDAWStore.setState(initialState); + }); + + it("removes one clip by ID without removing an identical same-file sibling", async () => { + const track = createDefaultTrack("track-audio", "Audio", "#38bdf8", "audio"); + track.clips = [ + audioClip({ id: "same-a", startTime: 2 }), + audioClip({ id: "same-b", startTime: 2 }), + ]; + const clearPlaybackClips = vi.spyOn(nativeBridge, "clearPlaybackClips").mockResolvedValue(true); + const addPlaybackClipsBatch = vi.spyOn(nativeBridge, "addPlaybackClipsBatch").mockResolvedValue(true); + const removePlaybackClipById = vi.spyOn(nativeBridge, "removePlaybackClipById").mockResolvedValue(true); + + useDAWStore.setState({ tracks: [track] }); + await useDAWStore.getState().syncClipsWithBackend(); + + expect(clearPlaybackClips).toHaveBeenCalledTimes(1); + expect(addPlaybackClipsBatch).toHaveBeenCalledTimes(1); + expect(addPlaybackClipsBatch.mock.calls[0][0]).toEqual(expect.arrayContaining([ + expect.objectContaining({ clipId: "same-a" }), + expect.objectContaining({ clipId: "same-b" }), + ])); + + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((entry) => entry.id === "track-audio" + ? { ...entry, clips: entry.clips.filter((clip) => clip.id !== "same-a") } + : entry), + })); + await useDAWStore.getState().syncClipsWithBackend(); + + expect(clearPlaybackClips).toHaveBeenCalledTimes(1); + expect(removePlaybackClipById).toHaveBeenCalledTimes(1); + expect(removePlaybackClipById).toHaveBeenCalledWith("track-audio", "same-a"); + expect(addPlaybackClipsBatch).toHaveBeenCalledTimes(1); + }); + + it("serializes an in-flight split sync so undo is the final backend state", async () => { + const track = createDefaultTrack("track-audio", "Audio", "#38bdf8", "audio"); + track.clips = [ + audioClip({ id: "race-target", startTime: 2, duration: 6 }), + audioClip({ id: "race-sibling-a", startTime: 12, duration: 2 }), + audioClip({ id: "race-sibling-b", startTime: 16, duration: 2 }), + ]; + const backendClipIds = new Set(); + let releaseTargetRemoval!: () => void; + const targetRemovalGate = new Promise((resolve) => { + releaseTargetRemoval = resolve; + }); + let blockTargetRemoval = false; + + vi.spyOn(nativeBridge, "clearPlaybackClips").mockImplementation(async () => { + backendClipIds.clear(); + return true; + }); + vi.spyOn(nativeBridge, "addPlaybackClipsBatch").mockImplementation(async (clips) => { + for (const clip of clips) backendClipIds.add(clip.clipId || ""); + return true; + }); + const removePlaybackClipById = vi + .spyOn(nativeBridge, "removePlaybackClipById") + .mockImplementation(async (_trackId, clipId) => { + backendClipIds.delete(clipId); + if (blockTargetRemoval && clipId === "race-target") { + await targetRemovalGate; + } + return true; + }); + + useDAWStore.setState({ + tracks: [track], + selectedClipIds: ["race-target"], + selectedClipId: "race-target", + selectedTrackIds: [], + selectedTrackId: null, + }); + await useDAWStore.getState().syncClipsWithBackend(); + expect([...backendClipIds].sort()).toEqual([ + "race-sibling-a", + "race-sibling-b", + "race-target", + ]); + + blockTargetRemoval = true; + useDAWStore.getState().splitClipAtPosition("race-target", 4); + await vi.waitFor(() => { + expect(removePlaybackClipById).toHaveBeenCalledWith("track-audio", "race-target"); + }); + const splitChildIds = useDAWStore.getState().tracks[0].clips + .filter((clip) => !clip.id.startsWith("race-sibling")) + .map((clip) => clip.id); + + useDAWStore.getState().undo(); + const latestStateBarrier = useDAWStore.getState().syncClipsWithBackend(); + releaseTargetRemoval(); + await latestStateBarrier; + + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual([ + "race-target", + "race-sibling-a", + "race-sibling-b", + ]); + expect([...backendClipIds].sort()).toEqual([ + "race-sibling-a", + "race-sibling-b", + "race-target", + ]); + for (const splitChildId of splitChildIds) { + expect(backendClipIds.has(splitChildId)).toBe(false); + } + }); + + it("retries one failed native rebuild and leaves the cache invalidated for the next sync", async () => { + const track = createDefaultTrack("track-audio", "Audio", "#38bdf8", "audio"); + track.clips = [audioClip({ id: "recovery-target" })]; + const clearPlaybackClips = vi + .spyOn(nativeBridge, "clearPlaybackClips") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const addPlaybackClipsBatch = vi + .spyOn(nativeBridge, "addPlaybackClipsBatch") + .mockResolvedValue(true); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + + useDAWStore.setState({ tracks: [track] }); + + await expect(useDAWStore.getState().syncClipsWithBackend()).rejects.toThrow( + "clearPlaybackClips returned false", + ); + expect(clearPlaybackClips).toHaveBeenCalledTimes(2); + expect(addPlaybackClipsBatch).not.toHaveBeenCalled(); + expect(warning).toHaveBeenCalledWith( + expect.stringContaining("retrying one full rebuild"), + expect.any(Error), + ); + + await useDAWStore.getState().syncClipsWithBackend(); + + expect(clearPlaybackClips).toHaveBeenCalledTimes(3); + expect(addPlaybackClipsBatch).toHaveBeenCalledTimes(1); + expect(addPlaybackClipsBatch).toHaveBeenCalledWith([ + expect.objectContaining({ clipId: "recovery-target" }), + ]); + }); + + it("logs a split sync failure after the bounded recovery attempt", async () => { + const track = createDefaultTrack("track-audio", "Audio", "#38bdf8", "audio"); + track.clips = [audioClip({ id: "failed-split" })]; + const clearPlaybackClips = vi + .spyOn(nativeBridge, "clearPlaybackClips") + .mockResolvedValue(false); + vi.spyOn(nativeBridge, "addPlaybackClipsBatch").mockResolvedValue(true); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + useDAWStore.setState({ + tracks: [track], + selectedClipIds: ["failed-split"], + selectedClipId: "failed-split", + }); + + useDAWStore.getState().splitClipAtPosition("failed-split", 4); + + await vi.waitFor(() => { + expect(errorLog).toHaveBeenCalledWith( + "[timeline.split] Backend clip sync failed after recovery attempt", + expect.any(Error), + ); + }); + expect(clearPlaybackClips).toHaveBeenCalledTimes(2); + }); + + it("makes reset an awaited barrier before direct clip inserts", async () => { + const track = createDefaultTrack("track-audio", "Audio", "#38bdf8", "audio"); + track.clips = [ + audioClip({ id: "barrier-source" }), + audioClip({ id: "barrier-sibling-a", startTime: 12 }), + audioClip({ id: "barrier-sibling-b", startTime: 20 }), + ]; + const backendClipIds: string[] = []; + let releaseRemoval!: () => void; + const removalGate = new Promise((resolve) => { + releaseRemoval = resolve; + }); + let blockRemoval = false; + + const clearPlaybackClips = vi + .spyOn(nativeBridge, "clearPlaybackClips") + .mockImplementation(async () => { + backendClipIds.splice(0, backendClipIds.length); + return true; + }); + vi.spyOn(nativeBridge, "addPlaybackClipsBatch").mockImplementation(async (clips) => { + for (const clip of clips) backendClipIds.push(clip.clipId || ""); + return true; + }); + const removePlaybackClipById = vi + .spyOn(nativeBridge, "removePlaybackClipById") + .mockImplementation(async (_trackId, clipId) => { + const index = backendClipIds.indexOf(clipId); + if (index >= 0) backendClipIds.splice(index, 1); + if (blockRemoval && clipId === "barrier-source") await removalGate; + return true; + }); + vi.spyOn(nativeBridge, "addPlaybackClip").mockImplementation(async ( + _trackId, + _filePath, + _startTime, + _duration, + _offset, + _volumeDB, + _fadeIn, + _fadeOut, + clipId, + ) => { + backendClipIds.push(clipId || ""); + return true; + }); + + useDAWStore.setState({ tracks: [track] }); + await useDAWStore.getState().syncClipsWithBackend(); + expect([...backendClipIds].sort()).toEqual([ + "barrier-sibling-a", + "barrier-sibling-b", + "barrier-source", + ]); + + blockRemoval = true; + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((entry) => entry.id === "track-audio" + ? { + ...entry, + clips: entry.clips.map((clip) => clip.id === "barrier-source" + ? { ...clip, startTime: 3 } + : clip), + } + : entry), + })); + const pendingSync = useDAWStore.getState().syncClipsWithBackend(); + await vi.waitFor(() => { + expect(removePlaybackClipById).toHaveBeenCalledWith("track-audio", "barrier-source"); + }); + + const { resetSyncCache } = await import("../store/actions/clips"); + const resetBarrier = resetSyncCache(); + releaseRemoval(); + await pendingSync; + await resetBarrier; + + const recordedClip = audioClip({ id: "direct-recording", startTime: 10 }); + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((entry) => entry.id === "track-audio" + ? { ...entry, clips: [...entry.clips, recordedClip] } + : entry), + })); + await nativeBridge.addPlaybackClip( + "track-audio", + recordedClip.filePath, + recordedClip.startTime, + recordedClip.duration, + recordedClip.offset, + recordedClip.volumeDB, + recordedClip.fadeIn, + recordedClip.fadeOut, + recordedClip.id, + ); + await useDAWStore.getState().syncClipsWithBackend(); + + expect(clearPlaybackClips).toHaveBeenCalledTimes(2); + expect([...backendClipIds].sort()).toEqual([ + "barrier-sibling-a", + "barrier-sibling-b", + "barrier-source", + "direct-recording", + ].sort()); + }); + + it("deletes an identity-aware same-file clip while rebuilding only its sibling", async () => { + const track = createDefaultTrack("track-audio", "Audio", "#38bdf8", "audio"); + track.clips = [ + audioClip({ id: "delete-a" }), + audioClip({ id: "keep-b" }), + ]; + const clearPlaybackClips = vi.spyOn(nativeBridge, "clearPlaybackClips").mockResolvedValue(true); + const addPlaybackClipsBatch = vi.spyOn(nativeBridge, "addPlaybackClipsBatch").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [track], + selectedClipIds: ["delete-a"], + selectedClipId: "delete-a", + rippleMode: "off", + }); + + useDAWStore.getState().deleteClip("delete-a"); + await vi.waitFor(() => expect(addPlaybackClipsBatch).toHaveBeenCalledTimes(1)); + + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual(["keep-b"]); + expect(clearPlaybackClips).toHaveBeenCalledTimes(1); + expect(addPlaybackClipsBatch).toHaveBeenCalledWith([ + expect.objectContaining({ trackId: "track-audio", clipId: "keep-b" }), + ]); + }); +}); diff --git a/frontend/src/__tests__/timelineTimeSelectionEditing.test.ts b/frontend/src/__tests__/timelineTimeSelectionEditing.test.ts new file mode 100644 index 0000000..d9d69ee --- /dev/null +++ b/frontend/src/__tests__/timelineTimeSelectionEditing.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; +import { serializeMIDIClipsForBackend } from "../utils/midiClipSerialization"; + +const originalState = useDAWStore.getState(); + +function audioClip(id: string, overrides: Partial = {}): AudioClip { + return { + id, + filePath: `C:/audio/${id}.wav`, + name: id, + startTime: 0, + duration: 4, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + ...overrides, + }; +} + +function sourceMIDIClip(): MIDIClip { + return { + id: "midi-source", + name: "MIDI source", + startTime: 1, + duration: 8, + offset: 2, + sourceStart: 0, + sourceLength: 12, + loopEnabled: false, + loopOffset: 0, + loopLength: 12, + events: [ + { timestamp: 2.5, type: "noteOn", note: 60, velocity: 100 }, + { timestamp: 3.5, type: "noteOff", note: 60, velocity: 0 }, + { timestamp: 4.5, type: "noteOn", note: 62, velocity: 100 }, + { timestamp: 5.5, type: "noteOff", note: 62, velocity: 0 }, + { timestamp: 7.5, type: "noteOn", note: 64, velocity: 100 }, + { timestamp: 8.5, type: "noteOff", note: 64, velocity: 0 }, + ], + ccEvents: [ + { cc: 1, time: 3, value: 30 }, + { cc: 1, time: 5, value: 50 }, + { cc: 1, time: 8, value: 80 }, + ], + color: "#f72585", + }; +} + +function serializedSummary(clip: MIDIClip) { + return serializeMIDIClipsForBackend([clip])[0].events.map((event) => ({ + type: event.type, + timestamp: event.timestamp, + note: event.note, + value: event.value, + })); +} + +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState((state) => ({ + tracks: [], + markers: [], + regions: [], + tempoMarkers: [], + selectedClipId: null, + selectedClipIds: [], + selectedTrackId: null, + selectedTrackIds: [], + selectedNoteIds: [], + timeSelection: null, + clipboard: { clip: null, clips: [], isCut: false }, + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + moveEnvelopesWithItems: true, + transport: { ...state.transport, currentTime: 0, tempo: 120 }, + syncClipsWithBackend: vi.fn(async () => undefined), + syncMIDITrackToBackend: vi.fn(async () => undefined), + canUndo: false, + canRedo: false, + })); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("atomic time-selection editing", () => { + it("deep-copies and cuts MIDI source windows with correct note/CC playback visibility", () => { + const track = createDefaultTrack("midi", "MIDI", "#f72585", "midi", []); + const source = sourceMIDIClip(); + track.midiClips = [source]; + useDAWStore.setState({ + tracks: [track], + timeSelection: { start: 3, end: 6 }, + selectedClipId: source.id, + selectedClipIds: [source.id], + }); + + useDAWStore.getState().copyWithinTimeSelection(); + const copied = useDAWStore.getState().clipboard.clip as MIDIClip; + expect(copied).toMatchObject({ startTime: 3, duration: 3, offset: 4 }); + expect(copied.events).not.toBe(source.events); + expect(copied.ccEvents).not.toBe(source.ccEvents); + expect(serializedSummary(copied)).toEqual([ + { type: "noteOn", timestamp: 0.5, note: 62, value: undefined }, + { type: "cc", timestamp: 1, note: undefined, value: 50 }, + { type: "noteOff", timestamp: 1.5, note: 62, value: undefined }, + ]); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.getState().cutWithinTimeSelection(); + let state = useDAWStore.getState(); + const fragments = state.tracks[0].midiClips; + expect(fragments.map((clip) => [clip.startTime, clip.duration, clip.offset])) + .toEqual([[1, 2, 2], [6, 3, 7]]); + expect(serializedSummary(fragments[0])).toEqual([ + { type: "noteOn", timestamp: 0.5, note: 60, value: undefined }, + { type: "cc", timestamp: 1, note: undefined, value: 30 }, + { type: "noteOff", timestamp: 1.5, note: 60, value: undefined }, + ]); + expect(serializedSummary(fragments[1])).toEqual([ + { type: "noteOn", timestamp: 0.5, note: 64, value: undefined }, + { type: "cc", timestamp: 1, note: undefined, value: 80 }, + { type: "noteOff", timestamp: 1.5, note: 64, value: undefined }, + ]); + expect(state.clipboard).toMatchObject({ isCut: true, sourceRemoved: true }); + expect(commandManager.getUndoStack()).toHaveLength(1); + const fragmentIds = fragments.map((clip) => clip.id); + + state.undo(); + expect(useDAWStore.getState().tracks[0].midiClips).toHaveLength(1); + expect(useDAWStore.getState().tracks[0].midiClips[0]).toMatchObject({ + id: source.id, + startTime: 1, + duration: 8, + offset: 2, + }); + state = useDAWStore.getState(); + state.redo(); + expect(useDAWStore.getState().tracks[0].midiClips.map((clip) => clip.id)) + .toEqual(fragmentIds); + }); + + it("ripple-deletes mixed audio/MIDI, automation, markers, and regions in one command", () => { + const audio = createDefaultTrack("audio", "Audio", "#38bdf8", "audio", []); + audio.clips = [audioClip("audio", { startTime: 1, duration: 5 })]; + audio.automationLanes = [{ + id: "lane", + param: "volume", + points: [ + { id: "before", time: 1, value: 0.1 }, + { id: "inside", time: 3, value: 0.3 }, + { id: "after", time: 5, value: 0.5 }, + ], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }]; + const midi = createDefaultTrack("midi", "MIDI", "#f72585", "midi", []); + midi.midiClips = [{ ...sourceMIDIClip(), startTime: 0, duration: 6, offset: 0 }]; + useDAWStore.setState({ + tracks: [audio, midi], + timeSelection: { start: 2, end: 4 }, + markers: [ + { id: "inside", time: 3, name: "Inside", color: "#fff" }, + { id: "after", time: 5, name: "After", color: "#fff" }, + ], + regions: [{ id: "region", name: "Span", startTime: 1, endTime: 5, color: "#fff" }], + selectedClipId: "midi-source", + selectedClipIds: ["audio", "midi-source"], + }); + + const action = getRegisteredAction("edit.deleteWithinSelection")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + let state = useDAWStore.getState(); + expect(state.tracks[0].clips.map((clip) => [clip.startTime, clip.duration, clip.offset])) + .toEqual([[1, 1, 0], [2, 2, 3]]); + expect(state.tracks[1].midiClips.map((clip) => [clip.startTime, clip.duration, clip.offset])) + .toEqual([[0, 2, 0], [2, 2, 4]]); + expect(state.tracks[0].automationLanes[0].points.map((point) => [point.id, point.time])) + .toEqual([["before", 1], ["after", 3]]); + expect(state.markers).toEqual([{ id: "after", time: 3, name: "After", color: "#fff" }]); + expect(state.regions).toEqual([{ id: "region", name: "Span", startTime: 1, endTime: 3, color: "#fff" }]); + expect(state.timeSelection).toBeNull(); + expect(commandManager.getUndoStack()).toHaveLength(1); + const idsAfterDelete = state.tracks.flatMap((track) => [ + ...track.clips.map((clip) => clip.id), + ...track.midiClips.map((clip) => clip.id), + ]); + + state.undo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points.map((point) => point.id)) + .toEqual(["before", "inside", "after"]); + state = useDAWStore.getState(); + state.redo(); + expect(useDAWStore.getState().tracks.flatMap((track) => [ + ...track.clips.map((clip) => clip.id), + ...track.midiClips.map((clip) => clip.id), + ])).toEqual(idsAfterDelete); + }); + + it("deletes mixed razor content atomically without touching locked clips", () => { + const audio = createDefaultTrack("audio", "Audio", "#38bdf8", "audio", []); + audio.clips = [audioClip("locked", { startTime: 0, locked: true })]; + audio.automationLanes = [{ + id: "lane", + param: "volume", + points: [ + { id: "keep", time: 0.5, value: 0.25 }, + { id: "remove", time: 1.5, value: 0.75 }, + ], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }]; + const midi = createDefaultTrack("midi", "MIDI", "#f72585", "midi", []); + midi.midiClips = [{ ...sourceMIDIClip(), startTime: 0, duration: 4, offset: 0 }]; + useDAWStore.setState({ + tracks: [audio, midi], + razorEdits: [ + { trackId: "audio", start: 1, end: 2 }, + { trackId: "midi", start: 1, end: 2 }, + ], + }); + + const action = getRegisteredAction("edit.delete")!; + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + let state = useDAWStore.getState(); + expect(state.tracks[0].clips.map((clip) => clip.id)).toEqual(["locked"]); + expect(state.tracks[0].automationLanes[0].points.map((point) => point.id)).toEqual(["keep"]); + expect(state.tracks[1].midiClips.map((clip) => [clip.startTime, clip.duration, clip.offset])) + .toEqual([[0, 1, 0], [2, 2, 2]]); + expect(state.razorEdits).toEqual([]); + expect(commandManager.getUndoStack()).toHaveLength(1); + const midiFragmentIds = state.tracks[1].midiClips.map((clip) => clip.id); + + state.undo(); + expect(useDAWStore.getState().tracks[0].automationLanes[0].points.map((point) => point.id)) + .toEqual(["keep", "remove"]); + expect(useDAWStore.getState().tracks[1].midiClips).toHaveLength(1); + expect(useDAWStore.getState().razorEdits).toHaveLength(2); + state = useDAWStore.getState(); + state.redo(); + expect(useDAWStore.getState().tracks[1].midiClips.map((clip) => clip.id)) + .toEqual(midiFragmentIds); + }); + + it("applies item, envelope, marker, global, and time-selection locks independently", () => { + const runInsert = (locks: { + items: boolean; + envelopes: boolean; + timeSelection: boolean; + markers: boolean; + }) => { + commandManager.clear(); + const track = createDefaultTrack("audio", "Audio", "#38bdf8", "audio", []); + track.clips = [audioClip("audio", { startTime: 3 })]; + track.automationLanes = [{ + id: "lane", + param: "volume", + points: [{ id: "point", time: 3.5, value: 0.5 }], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + }]; + useDAWStore.setState({ + tracks: [track], + timeSelection: { start: 1, end: 2 }, + markers: [{ id: "marker", time: 3, name: "Marker", color: "#fff" }], + globalLocked: false, + lockSettings: locks, + canUndo: false, + canRedo: false, + }); + useDAWStore.getState().insertSilenceAtTimeSelection(); + return useDAWStore.getState(); + }; + + let state = runInsert({ items: true, envelopes: false, timeSelection: false, markers: false }); + expect(state.tracks[0].clips[0].startTime).toBe(3); + expect(state.tracks[0].automationLanes[0].points[0].time).toBe(4.5); + expect(state.markers[0].time).toBe(4); + expect(commandManager.getUndoStack()).toHaveLength(1); + + state = runInsert({ items: false, envelopes: true, timeSelection: false, markers: false }); + expect(state.tracks[0].clips[0].startTime).toBe(4); + expect(state.tracks[0].automationLanes[0].points[0].time).toBe(3.5); + expect(state.markers[0].time).toBe(4); + + state = runInsert({ items: false, envelopes: false, timeSelection: false, markers: true }); + expect(state.tracks[0].clips[0].startTime).toBe(4); + expect(state.tracks[0].automationLanes[0].points[0].time).toBe(4.5); + expect(state.markers[0].time).toBe(3); + + state = runInsert({ items: true, envelopes: true, timeSelection: false, markers: true }); + expect(state.tracks[0].clips[0].startTime).toBe(3); + expect(state.tracks[0].automationLanes[0].points[0].time).toBe(3.5); + expect(state.markers[0].time).toBe(3); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(getRegisteredAction("edit.insertSilence")!.canHandleShortcut?.()).toBe(false); + + state = runInsert({ items: false, envelopes: true, timeSelection: true, markers: true }); + expect(state.tracks[0].clips[0].startTime).toBe(4); + expect(state.timeSelection).toEqual({ start: 1, end: 2 }); + + commandManager.clear(); + useDAWStore.setState({ globalLocked: true, canUndo: false, canRedo: false }); + expect(getRegisteredAction("edit.insertSilence")!.canHandleShortcut?.()).toBe(false); + useDAWStore.getState().insertSilenceAtTimeSelection(); + expect(useDAWStore.getState().tracks[0].clips[0].startTime).toBe(4); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("locks set/clear/deselect time-selection mutations without blocking other deselection", () => { + useDAWStore.setState({ + timeSelection: { start: 1, end: 2 }, + selectedClipId: "stale", + selectedClipIds: ["stale"], + lockSettings: { items: false, envelopes: false, timeSelection: true, markers: false }, + }); + useDAWStore.getState().setTimeSelection(3, 4); + useDAWStore.getState().clearTimeSelection(); + useDAWStore.getState().deselectAll(); + expect(useDAWStore.getState().timeSelection).toEqual({ start: 1, end: 2 }); + expect(useDAWStore.getState().selectedClipIds).toEqual([]); + + useDAWStore.setState({ globalLocked: true }); + useDAWStore.getState().setTimeSelection(5, 6); + useDAWStore.getState().clearTimeSelection(); + expect(useDAWStore.getState().timeSelection).toEqual({ start: 1, end: 2 }); + + useDAWStore.setState({ + globalLocked: false, + lockSettings: { items: false, envelopes: false, timeSelection: false, markers: false }, + }); + useDAWStore.getState().clearTimeSelection(); + expect(useDAWStore.getState().timeSelection).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/timelineTimeSelectionSplit.test.ts b/frontend/src/__tests__/timelineTimeSelectionSplit.test.ts new file mode 100644 index 0000000..308bb48 --- /dev/null +++ b/frontend/src/__tests__/timelineTimeSelectionSplit.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getRegisteredAction } from "../store/actionRegistry"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type MIDIClip, + useDAWStore, +} from "../store/useDAWStore"; + +const originalState = useDAWStore.getState(); + +function audioClip(overrides: Partial = {}): AudioClip { + return { + id: "audio", + filePath: "C:/audio.wav", + name: "Audio", + startTime: 0, + duration: 8, + offset: 1, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0.5, + fadeOut: 0.75, + sampleRate: 48_000, + sourceLength: 16, + ...overrides, + }; +} + +function midiClip(overrides: Partial = {}): MIDIClip { + return { + id: "midi", + name: "MIDI", + startTime: 0, + duration: 8, + offset: 2, + sourceStart: 0, + sourceLength: 12, + loopEnabled: false, + loopOffset: 0, + loopLength: 12, + events: [ + { timestamp: 1, type: "noteOn", note: 60, velocity: 100 }, + { timestamp: 6, type: "noteOff", note: 60, velocity: 0 }, + ], + ccEvents: [{ time: 2, cc: 1, value: 64 }], + color: "#f72585", + ...overrides, + }; +} + +beforeEach(() => { + commandManager.clear(); + const audio = createDefaultTrack("audio-track", "Audio", "#38bdf8", "audio"); + const midi = createDefaultTrack("midi-track", "MIDI", "#f72585", "midi"); + audio.clips = [audioClip()]; + midi.midiClips = [midiClip()]; + useDAWStore.setState({ + tracks: [audio, midi], + selectedClipId: "midi", + selectedClipIds: ["audio", "midi"], + selectedTrackId: null, + selectedTrackIds: [], + timeSelection: { start: 5, end: 2 }, + globalLocked: false, + lockSettings: { ...originalState.lockSettings, items: false }, + syncClipsWithBackend: vi.fn().mockResolvedValue(undefined), + syncMIDITrackToBackend: vi.fn().mockResolvedValue(undefined), + canUndo: false, + canRedo: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("split at time selection", () => { + it("splits selected audio and MIDI at both normalized boundaries as one stable transaction", () => { + const state = useDAWStore.getState(); + const syncAudio = state.syncClipsWithBackend as ReturnType; + const syncMIDI = state.syncMIDITrackToBackend as ReturnType; + const action = getRegisteredAction("edit.splitAtSelection")!; + + expect(action.canHandleShortcut?.()).toBe(true); + action.execute(); + + const split = useDAWStore.getState(); + expect(split.tracks[0].clips.map((clip) => [clip.startTime, clip.duration, clip.offset])) + .toEqual([[0, 2, 1], [2, 3, 3], [5, 3, 6]]); + expect(split.tracks[1].midiClips.map((clip) => [clip.startTime, clip.duration, clip.offset])) + .toEqual([[0, 2, 2], [2, 3, 4], [5, 3, 7]]); + expect(split.selectedClipIds).toHaveLength(6); + expect(split.selectedClipId).toBe(split.tracks[1].midiClips[2].id); + expect(new Set(split.selectedClipIds).size).toBe(6); + expect(split.tracks[1].midiClips[0].events).not.toBe(split.tracks[1].midiClips[1].events); + expect(syncAudio).toHaveBeenCalledTimes(1); + expect(syncMIDI).toHaveBeenCalledWith("midi-track", { debounce: false }); + expect(commandManager.getUndoStack()).toHaveLength(1); + + const splitIds = split.tracks.flatMap((track) => [ + ...track.clips.map((clip) => clip.id), + ...track.midiClips.map((clip) => clip.id), + ]); + split.undo(); + expect(useDAWStore.getState().tracks[0].clips.map((clip) => clip.id)).toEqual(["audio"]); + expect(useDAWStore.getState().tracks[1].midiClips.map((clip) => clip.id)).toEqual(["midi"]); + expect(useDAWStore.getState().selectedClipIds).toEqual(["audio", "midi"]); + + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.flatMap((track) => [ + ...track.clips.map((clip) => clip.id), + ...track.midiClips.map((clip) => clip.id), + ])).toEqual(splitIds); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("honors selected-clip precedence, individual locks, item locks, and exact no-op availability", () => { + useDAWStore.setState((state) => ({ + tracks: state.tracks.map((track) => track.id === "audio-track" + ? { ...track, clips: track.clips.map((clip) => ({ ...clip, locked: true })) } + : track), + selectedClipId: "audio", + selectedClipIds: ["audio"], + })); + const action = getRegisteredAction("edit.splitAtSelection")!; + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().tracks[1].midiClips).toHaveLength(1); + + useDAWStore.setState((state) => ({ + selectedClipId: "midi", + selectedClipIds: ["midi"], + lockSettings: { ...state.lockSettings, items: true }, + })); + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + expect(commandManager.getUndoStack()).toHaveLength(0); + + useDAWStore.setState((state) => ({ + lockSettings: { ...state.lockSettings, items: false }, + globalLocked: true, + })); + expect(action.canHandleShortcut?.()).toBe(false); + action.execute(); + expect(commandManager.getUndoStack()).toHaveLength(0); + }); + + it("rejects missing, zero-length, non-finite, and boundary-only selections", () => { + const action = getRegisteredAction("edit.splitAtSelection")!; + for (const timeSelection of [ + null, + { start: 2, end: 2 }, + { start: Number.NaN, end: 3 }, + { start: 0, end: 8 }, + ]) { + useDAWStore.setState({ timeSelection }); + expect(action.canHandleShortcut?.(), JSON.stringify(timeSelection)).toBe(false); + action.execute(); + expect(commandManager.getUndoStack()).toHaveLength(0); + } + }); +}); diff --git a/frontend/src/__tests__/timelineTrackDrop.test.ts b/frontend/src/__tests__/timelineTrackDrop.test.ts index ae8c857..33e83c6 100644 --- a/frontend/src/__tests__/timelineTrackDrop.test.ts +++ b/frontend/src/__tests__/timelineTrackDrop.test.ts @@ -23,4 +23,12 @@ describe("timeline track drop regression guards", () => { expect(trackActionsSource).toContain("const includeAddTrack = !(options.backendAlreadyCreated && !hasExecutedOnce)"); expect(trackActionsSource).toContain("syncTrackCoreToBackend(fullTrack, { includeAddTrack })"); }); + + it("dedupes external file drops and allows multi-track MIDI files to land on one compatible track", () => { + expect(timelineSource).toContain("dedupeExternalMediaFiles"); + expect(timelineSource).toContain("processedExternalDropKeysRef"); + expect(timelineSource).toContain("getExternalMediaDropKey"); + expect(timelineSource).toContain('isExternalTrackCompatible(targetTrack, "midi");'); + expect(timelineSource).not.toContain("(preview.midiTrackCount ?? 1) <= 1"); + }); }); diff --git a/frontend/src/__tests__/timelineZoomSemantics.test.ts b/frontend/src/__tests__/timelineZoomSemantics.test.ts new file mode 100644 index 0000000..dba6a52 --- /dev/null +++ b/frontend/src/__tests__/timelineZoomSemantics.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getRegisteredAction, + registerScopedActionExecutor, +} from "../store/actionRegistry"; +import { createDefaultTrack, useDAWStore } from "../store/useDAWStore"; +import { + getTimelineProjectFitView, + getTimelineRangeFitView, +} from "../utils/contextWheelBehaviors"; +import { dispatchGlobalShortcut } from "../utils/globalShortcutDispatcher"; +import { + activateShortcutContext, + registerShortcutSurface, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; + +const originalState = useDAWStore.getState(); +const cleanup: Array<() => void> = []; + +beforeEach(() => { + resetShortcutContextForTests(); + useDAWStore.setState({ + tracks: [], + recordingClips: [], + timeSelection: null, + keyboardShortcutProfileId: "openstudio", + customShortcuts: {}, + transport: { ...originalState.transport, currentTime: 0 }, + }); +}); + +afterEach(() => { + while (cleanup.length > 0) cleanup.pop()?.(); + vi.restoreAllMocks(); + resetShortcutContextForTests(); + useDAWStore.setState(originalState); +}); + +describe("Timeline zoom semantics", () => { + it("fits mixed audio/MIDI content against the actual viewport and responds to resize", () => { + const tracks = [{ + clips: [{ startTime: 2, duration: 8 }], + midiClips: [{ startTime: 15, duration: 5 }], + }]; + expect(getTimelineProjectFitView(tracks, undefined, 1000)).toEqual({ + pixelsPerSecond: 50, + scrollX: 0, + }); + expect(getTimelineProjectFitView(tracks, undefined, 500)).toEqual({ + pixelsPerSecond: 25, + scrollX: 0, + }); + }); + + it("includes active recording extent, ignores invalid clips, and clamps supported zoom", () => { + const tracks = [{ + clips: [ + { startTime: Number.NaN, duration: 10 }, + { startTime: 0, duration: -4 }, + ], + midiClips: [{ startTime: Number.POSITIVE_INFINITY, duration: 1 }], + }]; + expect(getTimelineProjectFitView(tracks, 4, 800)).toEqual({ + pixelsPerSecond: 200, + scrollX: 0, + }); + expect(getTimelineProjectFitView([{ clips: [{ startTime: 0, duration: 0.001 }], midiClips: [] }], null, 800)) + .toEqual({ pixelsPerSecond: 1000, scrollX: 0 }); + expect(getTimelineProjectFitView([{ clips: [{ startTime: 0, duration: 10_000 }], midiClips: [] }], null, 100)) + .toEqual({ pixelsPerSecond: 1, scrollX: 0 }); + }); + + it("returns no fit for an empty/invalid viewport and fits normalized selections with margin", () => { + expect(getTimelineProjectFitView([], undefined, 800)).toBeNull(); + expect(getTimelineProjectFitView([{ clips: [{ startTime: 0, duration: 1 }], midiClips: [] }], null, 0)) + .toBeNull(); + expect(getTimelineProjectFitView([{ clips: [{ startTime: 0, duration: 1 }], midiClips: [] }], null, Number.NaN)) + .toBeNull(); + expect(getTimelineRangeFitView(6, 2, 1000)).toEqual({ + pixelsPerSecond: 200, + scrollX: 300, + }); + expect(getTimelineRangeFitView(2, 2, 1000)).toBeNull(); + expect(getTimelineRangeFitView(Number.NaN, 2, 1000)).toBeNull(); + }); + + it("routes both zoom commands only to the active Timeline owner", () => { + const audio = createDefaultTrack("track", "Track", "#38bdf8", "audio"); + audio.clips = [{ + id: "clip", + filePath: "C:/audio.wav", + name: "Audio", + startTime: 0, + duration: 20, + offset: 0, + color: "#38bdf8", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + }]; + useDAWStore.setState({ + tracks: [audio], + timeSelection: { start: 2, end: 6 }, + }); + const executedActionIds: string[] = []; + const execute = vi.fn((actionId: string) => { + executedActionIds.push(actionId); + return "handled" as const; + }); + cleanup.push(registerScopedActionExecutor( + { kind: "timeline" }, + execute, + ["view.zoomToFit", "view.zoomToSelection"], + )); + activateShortcutContext({ kind: "timeline" }); + + const fit = getRegisteredAction("view.zoomToFit")!; + const selection = getRegisteredAction("view.zoomToSelection")!; + expect(fit.canHandleShortcut?.()).toBe(true); + expect(selection.canHandleShortcut?.()).toBe(true); + fit.execute(); + selection.execute(); + expect(executedActionIds).toEqual([ + "view.zoomToFit", + "view.zoomToSelection", + ]); + + activateShortcutContext({ kind: "application" }); + expect(fit.canHandleShortcut?.()).toBe(false); + expect(selection.canHandleShortcut?.()).toBe(false); + }); + + it("dispatches Waveform's F8 project-fit binding through the production resolver", () => { + const midi = createDefaultTrack("midi", "MIDI", "#f72585", "midi"); + midi.midiClips = [{ + id: "midi-clip", + name: "MIDI", + startTime: 0, + duration: 16, + offset: 0, + sourceStart: 0, + sourceLength: 16, + loopEnabled: false, + loopOffset: 0, + loopLength: 16, + events: [], + ccEvents: [], + color: "#f72585", + }]; + useDAWStore.setState({ + tracks: [midi], + keyboardShortcutProfileId: "waveform", + }); + const execute = vi.fn(() => "handled" as const); + cleanup.push(registerShortcutSurface({ kind: "timeline" }, () => "unmatched")); + cleanup.push(registerScopedActionExecutor( + { kind: "timeline" }, + execute, + ["view.zoomToFit"], + )); + activateShortcutContext({ kind: "timeline" }); + + expect(dispatchGlobalShortcut({ key: "F8", code: "F8", source: "browser" }, "windows")) + .toBe(true); + expect(execute).toHaveBeenCalledWith("view.zoomToFit"); + }); +}); diff --git a/frontend/src/__tests__/tone3000InfiniteAppend.test.ts b/frontend/src/__tests__/tone3000InfiniteAppend.test.ts new file mode 100644 index 0000000..2a3635c --- /dev/null +++ b/frontend/src/__tests__/tone3000InfiniteAppend.test.ts @@ -0,0 +1,105 @@ +// @ts-expect-error The app tsconfig omits Node builtin typings, while Vitest runs this source audit in Node. +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { + createTONE3000AppendGate, + observeTONE3000AppendSentinel, + shouldRetryTONE3000Append, +} from "../utils/tone3000InfiniteAppend"; + +describe("TONE3000 infinite append gate", () => { + it("allows one request per search/page and advances only to a new key", () => { + const gate = createTONE3000AppendGate(); + const first = gate.begin("mesa:a2:page:2"); + + expect(first).not.toBeNull(); + expect(gate.begin("mesa:a2:page:2")).toBeNull(); + gate.settle(first!, "success"); + expect(gate.begin("mesa:a2:page:2")).toBeNull(); + expect(gate.begin("mesa:a2:page:3")).not.toBeNull(); + }); + + it("blocks an automatic error loop but allows the accessible manual retry", () => { + const gate = createTONE3000AppendGate(); + const first = gate.begin("clean:a1:page:4"); + gate.settle(first!, "error"); + + expect(gate.begin("clean:a1:page:4")).toBeNull(); + expect(gate.begin("clean:a1:page:4", true)).not.toBeNull(); + }); + + it("releases stale work and resets state for a changed search signature", () => { + const gate = createTONE3000AppendGate(); + const first = gate.begin("old:page:2"); + gate.settle(first!, "stale"); + expect(gate.begin("old:page:2")).not.toBeNull(); + + gate.reset(); + expect(gate.begin("old:page:2")).not.toBeNull(); + }); + + it("routes only the matching failed append through manual load-more retry", () => { + const failure = { + mode: "append" as const, + page: 3, + signature: "mesa:a2", + status: "Live TONE3000 search failed", + }; + + expect(shouldRetryTONE3000Append( + failure, + "mesa:a2", + "Live TONE3000 search failed", + true, + )).toBe(true); + expect(shouldRetryTONE3000Append(failure, "clean:a2", failure.status, true)).toBe(false); + expect(shouldRetryTONE3000Append(failure, failure.signature, "Catalog unavailable", true)).toBe(false); + expect(shouldRetryTONE3000Append({ ...failure, mode: "replace" }, failure.signature, failure.status, true)).toBe(false); + expect(shouldRetryTONE3000Append(failure, failure.signature, failure.status, false)).toBe(false); + }); +}); + +describe("TONE3000 append sentinel", () => { + it("observes the real scroll root at the viewport edge and disconnects cleanly", () => { + const target = {} as Element; + const root = {} as Element; + const observe = vi.fn(); + const disconnect = vi.fn(); + const onIntersect = vi.fn(); + let callback!: IntersectionObserverCallback; + let options!: IntersectionObserverInit; + + const cleanup = observeTONE3000AppendSentinel(target, root, onIntersect, (nextCallback, nextOptions) => { + callback = nextCallback; + options = nextOptions; + return { observe, disconnect }; + }); + + expect(observe).toHaveBeenCalledWith(target); + expect(options).toMatchObject({ root, rootMargin: "0px", threshold: 0.01 }); + + callback([{ isIntersecting: false, intersectionRatio: 0 } as IntersectionObserverEntry], {} as IntersectionObserver); + expect(onIntersect).not.toHaveBeenCalled(); + callback([{ isIntersecting: true, intersectionRatio: 1 } as IntersectionObserverEntry], {} as IntersectionObserver); + expect(onIntersect).toHaveBeenCalledTimes(1); + + cleanup(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it("is a no-op when the sentinel is not mounted", () => { + const factory = vi.fn(); + const cleanup = observeTONE3000AppendSentinel(null, null, vi.fn(), factory); + + expect(factory).not.toHaveBeenCalled(); + expect(cleanup()).toBeUndefined(); + }); + + it("re-arms source-flow observation when the search/page request signature changes", () => { + const explorerSource = readFileSync( + new URL("../components/NAMExplorer.tsx", import.meta.url), + "utf8", + ); + expect(explorerSource).toContain('requestKey: `${currentLiveSearchSignature}:page:${livePage + 1}`'); + }); +}); diff --git a/frontend/src/__tests__/tone3000LiveSearch.test.ts b/frontend/src/__tests__/tone3000LiveSearch.test.ts new file mode 100644 index 0000000..7f1b3ba --- /dev/null +++ b/frontend/src/__tests__/tone3000LiveSearch.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + TONE3000_QUERY_DEBOUNCE_MS, + buildTONE3000LiveSearchSnapshot, + createTONE3000QueryDebouncer, + createTONE3000SearchEpoch, +} from "../utils/tone3000LiveSearch"; + +function snapshot(overrides: Partial[0]> = {}) { + return buildTONE3000LiveSearchSnapshot({ + query: "", + page: 1, + pageSize: 12, + targetPageSize: 24, + requestedSort: "trending", + sortMode: "trending", + tab: "trending", + gearFilter: "amp_amp-cab", + format: "nam", + architecture: "a2", + sourceFlow: "amp", + sourceFlowCategoryFilter: "all", + includeModels: false, + ...overrides, + }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("TONE3000 live search request snapshots", () => { + it("trims text and requests best-match for every non-empty query", () => { + const request = snapshot({ query: " mesa lead ", requestedSort: "newest" }); + + expect(request.query).toBe("mesa lead"); + expect(request.sort).toBe("best-match"); + expect(Object.isFrozen(request)).toBe(true); + }); + + it("preserves the selected API sort for an empty query", () => { + expect(snapshot({ query: " ", requestedSort: "downloads-all-time" }).sort).toBe("downloads-all-time"); + }); + + it("uses one search signature across pages and a distinct cache key per page", () => { + const first = snapshot({ query: "clean", page: 1 }); + const second = snapshot({ query: "clean", page: 2 }); + + expect(first.signature).toBe(second.signature); + expect(first.cacheKey).not.toBe(second.cacheKey); + }); +}); + +describe("TONE3000 live search request epochs", () => { + it("rejects stale success, error, and finally handlers after a replacement starts", () => { + const epoch = createTONE3000SearchEpoch(); + const first = epoch.begin(snapshot({ architecture: "a1" }).signature); + const second = epoch.begin(snapshot({ architecture: "a2" }).signature); + + expect(epoch.isCurrent(first)).toBe(false); + expect(epoch.isCurrent(second)).toBe(true); + + epoch.invalidate(); + expect(epoch.isCurrent(second)).toBe(false); + }); +}); + +describe("TONE3000 query debounce", () => { + it("commits only the latest value after 400 ms", () => { + vi.useFakeTimers(); + const committed = vi.fn(); + const debounce = createTONE3000QueryDebouncer(committed); + + debounce.schedule("m"); + debounce.schedule("me"); + debounce.schedule("mesa"); + vi.advanceTimersByTime(TONE3000_QUERY_DEBOUNCE_MS - 1); + expect(committed).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(committed).toHaveBeenCalledTimes(1); + expect(committed).toHaveBeenLastCalledWith("mesa"); + }); + + it("flushes Enter/search immediately and cancels the pending duplicate", () => { + vi.useFakeTimers(); + const committed = vi.fn(); + const debounce = createTONE3000QueryDebouncer(committed); + + debounce.schedule("modern high gain"); + debounce.flush("modern high gain"); + expect(committed).toHaveBeenCalledTimes(1); + expect(committed).toHaveBeenLastCalledWith("modern high gain"); + + vi.advanceTimersByTime(TONE3000_QUERY_DEBOUNCE_MS * 2); + expect(committed).toHaveBeenCalledTimes(1); + }); + + it("does not commit after cancellation", () => { + vi.useFakeTimers(); + const committed = vi.fn(); + const debounce = createTONE3000QueryDebouncer(committed); + + debounce.schedule("a2 clean"); + debounce.cancel(); + vi.runAllTimers(); + + expect(committed).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/tone3000MockSearch.test.ts b/frontend/src/__tests__/tone3000MockSearch.test.ts new file mode 100644 index 0000000..94931d9 --- /dev/null +++ b/frontend/src/__tests__/tone3000MockSearch.test.ts @@ -0,0 +1,334 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + boundNAMCatalogRowsForDisplay, + classifyNAMSourceCategory, + mergeTONE3000TonePages, + modelArchitecture, + resolveNAMCatalogSelection, + tone3000LivePageSizing, +} from "../components/NAMExplorer"; +import { nativeBridge } from "../services/NativeBridge"; +import { namCatalogCaptureSelectionKey } from "../utils/namToneCaptureSelection"; + +describe("TONE3000 dev NAM search mock", () => { + beforeEach(() => { + vi.stubGlobal("window", { + location: { + search: "?mockPlugin=nam", + }, + }); + }); + + it("respects sort, page, page_size, totals, and total pages", async () => { + const firstPage = await nativeBridge.searchTONE3000NAM({ + page: 1, + page_size: 2, + sort: "downloads-all-time", + gears: "amp_amp-cab", + architecture: "all", + }); + const secondPage = await nativeBridge.searchTONE3000NAM({ + page: 2, + page_size: 2, + sort: "downloads-all-time", + gears: "amp_amp-cab", + architecture: "all", + }); + + expect(firstPage.success).toBe(true); + expect(firstPage.page).toBe(1); + expect(firstPage.page_size).toBe(2); + expect(firstPage.total).toBe(72); + expect(firstPage.total_pages).toBe(36); + expect(firstPage.has_more).toBe(true); + expect(firstPage.next_page).toBe(2); + expect(firstPage.tones?.map((tone) => tone.title)).toEqual([ + "Crisp Twin Clean A2", + "Jazz Chorus Glass", + ]); + expect(secondPage.page).toBe(2); + expect(secondPage.tones?.map((tone) => tone.title)).toEqual([ + "Edge Clean Breakup", + "High Gain Modern", + ]); + }); + + it("models preview-to-library promotion with a new durable path", async () => { + const preview = await nativeBridge.installNAMModel({ + id: 5320302, + model_id: 5320302, + tone_id: 53203, + name: "Classic Crunch A2", + model_url: "https://example.invalid/classic-crunch-a2.nam", + }, { mode: "preview" }); + + expect(preview.record?.preview).toBe(true); + expect(preview.record?.localPath.replace(/\\/g, "/")).toContain("/previews/"); + + const committed = await nativeBridge.commitNAMPreviewTone(preview.record!, { + toneName: "Classic Crunch", + }); + + expect(committed.success).toBe(true); + expect(committed.record?.preview).toBe(false); + expect(committed.record?.localPath.replace(/\\/g, "/")).toContain("/library/tone-53203/"); + expect(committed.record?.localPath).not.toBe(preview.record?.localPath); + }); + + it("keeps each UI surface inside a bounded live-search page budget", () => { + expect(tone3000LivePageSizing("rail", "all")).toEqual({ + targetPageSize: 4, + apiPageSize: 2, + }); + expect(tone3000LivePageSizing("source-flow", "all")).toEqual({ + targetPageSize: 12, + apiPageSize: 6, + }); + expect(tone3000LivePageSizing("full", "all")).toEqual({ + targetPageSize: 24, + apiPageSize: 12, + }); + expect(tone3000LivePageSizing("full", "a2")).toEqual({ + targetPageSize: 24, + apiPageSize: 24, + }); + }); + + it("bounds the initial saved-catalog DOM per surface without truncating live append results", () => { + const rows = Array.from({ length: 72 }, (_, index) => `tone-${index + 1}`); + + expect(boundNAMCatalogRowsForDisplay(rows, "rail", "cache", "latest")).toHaveLength(4); + expect(boundNAMCatalogRowsForDisplay(rows, "source-flow", "cache", "latest")).toHaveLength(12); + expect(boundNAMCatalogRowsForDisplay(rows, "full", "cache", "latest")).toHaveLength(24); + expect(boundNAMCatalogRowsForDisplay(rows, "full", "live", "latest")).toBe(rows); + expect(boundNAMCatalogRowsForDisplay(rows, "source-flow", "cache", "installed")).toBe(rows); + expect(boundNAMCatalogRowsForDisplay(rows, "source-flow", "cache", "favorites")).toBe(rows); + }); + + it("deduplicates appended server pages by stable tone identity", () => { + const first = [ + { id: 100, title: "First" }, + { id: 101, title: "Second" }, + ]; + const second = [ + { id: 101, title: "Second duplicate from another architecture page" }, + { id: 102, title: "Third" }, + ]; + + expect(mergeTONE3000TonePages(first, second, true).map((tone) => tone.id)).toEqual([100, 101, 102]); + expect(mergeTONE3000TonePages(first, second, false).map((tone) => tone.id)).toEqual([101, 102]); + }); + + it("returns a compact newest page that matches the rack rail browse shape", async () => { + const payload = await nativeBridge.searchTONE3000NAM({ + page: 1, + page_size: 4, + sort: "newest", + gears: "amp_amp-cab", + architecture: "all", + }); + + expect(payload.success).toBe(true); + expect(payload.page).toBe(1); + expect(payload.page_size).toBe(4); + expect(payload.total).toBe(72); + expect(payload.total_pages).toBe(18); + expect(payload.tones?.map((tone) => tone.title)).toEqual([ + "Headbangers Ball Amp Pack IR/RAW", + "Ambient Glow", + "Classic Crunch", + "High Gain Modern", + ]); + }); + + it("preserves A2 identity on summary-only search rows", async () => { + const payload = await nativeBridge.searchTONE3000NAM({ + page: 1, + page_size: 4, + sort: "newest", + gears: "amp_amp-cab", + architecture: "a2", + includeModels: false, + }); + + expect(payload.success).toBe(true); + expect(payload.total).toBeGreaterThan(0); + expect(payload.tones?.length).toBeGreaterThan(0); + for (const tone of payload.tones ?? []) { + expect(tone.models).toBeUndefined(); + expect(tone.searchArchitecture).toBe("2"); + expect(modelArchitecture(tone, {})).toBe("A2"); + } + }); + + it("separates cabinet IR rows from space IR source material", async () => { + const payload = await nativeBridge.searchTONE3000NAM({ + query: "ir", + page: 1, + page_size: 20, + sort: "name-az", + gears: "ir", + architecture: "all", + }); + + expect(payload.success).toBe(true); + const categories = new Set((payload.tones ?? []).map((tone) => classifyNAMSourceCategory(tone.gear, tone.character, tone.description))); + expect(categories.has("cabinet-ir")).toBe(true); + expect(categories.has("space-ir")).toBe(true); + }); + + it("supports the production cabinet IR request without NAM format conflicts", async () => { + const payload = await nativeBridge.searchTONE3000NAM({ + page: 1, + page_size: 20, + sort: "trending", + gears: "cab", + format: "ir", + architecture: "", + includeModels: false, + }); + + expect(payload.success).toBe(true); + expect(payload.total).toBeGreaterThan(0); + expect(payload.tones?.length).toBeGreaterThan(0); + for (const tone of payload.tones ?? []) { + expect(tone.models).toBeUndefined(); + expect(tone.platform).toBe("ir"); + expect(classifyNAMSourceCategory(tone.gear, tone.character, tone.description)).toBe("cabinet-ir"); + } + }); + + it("hydrates an IR tone without applying a NAM architecture filter", async () => { + const detail = await nativeBridge.getTONE3000ToneDetail(53109, ""); + + expect(detail.success).toBe(true); + expect(detail.models).toHaveLength(1); + expect(detail.models?.[0]?.model_url).toMatch(/\.wav$/); + }); + + it("keeps mock cabinet IR installs as audio files", async () => { + const payload = await nativeBridge.searchTONE3000NAM({ + query: "cabinet", + page: 1, + page_size: 5, + sort: "name-az", + gears: "ir", + architecture: "all", + }); + const cabinetModel = payload.tones?.flatMap((tone) => tone.models ?? []) + .find((model) => classifyNAMSourceCategory(model.gear_type, model.name, model.model_url) === "cabinet-ir"); + + expect(cabinetModel).toBeTruthy(); + const result = await nativeBridge.installNAMModel(cabinetModel!, { mode: "preview" }); + + expect(result.success).toBe(true); + expect(result.record?.localPath).toMatch(/\.wav$/); + expect(result.record?.gearType).toBe("cabinet-ir"); + }); + + it("hydrates tone detail with architecture-filtered models", async () => { + const detail = await nativeBridge.getTONE3000ToneDetail(53101, "a2"); + + expect(detail.success).toBe(true); + expect(detail.tone?.title).toBe("Crisp Twin Clean A2"); + expect(detail.models).toHaveLength(1); + expect(detail.models?.[0]?.model_url).toContain("crisp-twin-clean-a2.nam"); + expect(detail.models?.[0]?.inputCalibrationDb).toBe(-12); + expect(detail.models?.[0]?.normalization).toMatchObject({ + mode: "A2 calibrated", + targetLufs: -18, + }); + }); + + it("keeps a summary-only tone selected when hydration replaces its placeholder model key", () => { + const hydratedRows = [ + { + key: "53101:5310102:trending:0:0", + tone: { id: 53101, title: "Crisp Twin Clean A2" }, + model: { id: 5310102, name: "Crisp Twin Clean A2" }, + }, + { + key: "53102:5310202:trending:1:0", + tone: { id: 53102, title: "Other tone" }, + model: { id: 5310202, name: "Other model" }, + }, + ]; + + expect(resolveNAMCatalogSelection( + hydratedRows, + "53101:0:trending:0:0", + hydratedRows[1], + )?.key).toBe("53101:5310102:trending:0:0"); + + const multiModelRows = [ + hydratedRows[0], + { + key: "53101:5310103:trending:0:1", + tone: hydratedRows[0].tone, + model: { id: 5310103, name: "Chosen full rig" }, + }, + ]; + expect(resolveNAMCatalogSelection( + multiModelRows, + "53101:5310103", + multiModelRows[0], + )?.key).toBe("53101:5310103:trending:0:1"); + }); + + it("resolves an exact URL-only capture instead of falling back to the first pack row", () => { + const tone = { id: 88001, title: "URL-only pack" }; + const clean = { name: "Clean", model_url: "https://example.invalid/captures/clean.nam" }; + const lead = { name: "Lead", model_url: "https://example.invalid/captures/lead.nam" }; + const rows = [ + { key: "88001:0:latest:0:0", tone, model: clean }, + { key: "88001:0:latest:0:1", tone, model: lead }, + ]; + + expect(resolveNAMCatalogSelection( + rows, + namCatalogCaptureSelectionKey(tone, lead), + rows[0], + )?.key).toBe(rows[1].key); + expect(resolveNAMCatalogSelection(rows, "88001:0", rows[1])?.key).toBe(rows[0].key); + }); + + it("returns not found for missing mock tone details", async () => { + const detail = await nativeBridge.getTONE3000ToneDetail(999999, "all"); + + expect(detail.success).toBe(false); + expect(detail.statusCode).toBe(404); + }); + + it("preserves install metadata and supports preview rack load", async () => { + const detail = await nativeBridge.getTONE3000ToneDetail(53101, "a2"); + const model = detail.models?.[0]; + expect(model).toBeTruthy(); + + const result = await nativeBridge.installNAMModel({ + ...model!, + toneTitle: detail.tone?.title, + creator: detail.tone?.creator, + gearType: detail.tone?.gear, + license: "Free", + }, { mode: "preview" }); + + expect(result.success).toBe(true); + expect(result.record?.preview).toBe(true); + expect(result.record?.toneTitle).toBe("Crisp Twin Clean A2"); + expect(result.record?.creator).toBe("OpenStudio QA"); + expect(result.record?.sourceProvider).toBe("tone3000"); + expect(result.record?.lastSeenMetadata?.inputCalibrationDb).toBe(-12); + + await expect(nativeBridge.loadNAMModelIntoRack({ + chain: "track", + trackId: "track-1", + fxIndex: 0, + }, "amp", result.record!.localPath)).resolves.toBe(true); + const loadedState = await nativeBridge.getBuiltInPluginState({ + chain: "track", + trackId: "track-1", + fxIndex: 0, + }); + expect(loadedState.modelState?.ampModelSize).toBe(1); + }); +}); diff --git a/frontend/src/__tests__/tone3000Session.test.ts b/frontend/src/__tests__/tone3000Session.test.ts new file mode 100644 index 0000000..4ad6b79 --- /dev/null +++ b/frontend/src/__tests__/tone3000Session.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { + bootstrapTONE3000Session, + ensureTONE3000Session, + getTONE3000SessionSnapshot, + resetTONE3000SessionForTests, + startTONE3000InteractiveAuth, +} from "../services/tone3000Session"; + +describe("TONE3000 shared session", () => { + beforeEach(() => { + vi.restoreAllMocks(); + resetTONE3000SessionForTests(); + }); + + it("uses a valid stored token on startup without opening browser auth", async () => { + const startAuth = vi.spyOn(nativeBridge, "startTONE3000AuthFlow"); + const refresh = vi.spyOn(nativeBridge, "refreshTONE3000Auth"); + vi.spyOn(nativeBridge, "getTONE3000AuthStatus").mockResolvedValue({ + success: true, + authenticated: true, + expired: false, + hasRefreshToken: true, + clientId: "client-id", + }); + + await bootstrapTONE3000Session(); + + expect(refresh).not.toHaveBeenCalled(); + expect(startAuth).not.toHaveBeenCalled(); + expect(getTONE3000SessionSnapshot().status?.authenticated).toBe(true); + }); + + it("silently refreshes an expired stored token once on startup", async () => { + let refreshed = false; + const startAuth = vi.spyOn(nativeBridge, "startTONE3000AuthFlow"); + vi.spyOn(nativeBridge, "getTONE3000AuthStatus").mockImplementation(async () => ({ + success: true, + authenticated: true, + expired: !refreshed, + hasRefreshToken: true, + clientId: "client-id", + })); + const refresh = vi.spyOn(nativeBridge, "refreshTONE3000Auth").mockImplementation(async () => { + refreshed = true; + return { success: true, authenticated: true, hasRefreshToken: true, clientId: "client-id" }; + }); + + const status = await bootstrapTONE3000Session(); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(startAuth).not.toHaveBeenCalled(); + expect(status?.authenticated).toBe(true); + expect(status?.expired).toBe(false); + }); + + it("does not open browser auth when no stored token exists", async () => { + const startAuth = vi.spyOn(nativeBridge, "startTONE3000AuthFlow"); + vi.spyOn(nativeBridge, "getTONE3000AuthStatus").mockResolvedValue({ + success: true, + authenticated: false, + hasRefreshToken: false, + }); + + const result = await ensureTONE3000Session("live search"); + + expect(result.ok).toBe(false); + expect(result.message).toContain("Connect TONE3000"); + expect(startAuth).not.toHaveBeenCalled(); + }); + + it("connects a first-time user through the native browser flow without exposing a token", async () => { + const getStatus = vi.spyOn(nativeBridge, "getTONE3000AuthStatus") + .mockResolvedValueOnce({ + success: true, + authenticated: false, + hasRefreshToken: false, + configuredClientId: true, + }) + .mockResolvedValue({ + success: true, + authenticated: true, + expired: false, + hasRefreshToken: true, + configuredClientId: true, + clientId: "publishable-client-id", + }); + const startAuth = vi.spyOn(nativeBridge, "startTONE3000AuthFlow").mockResolvedValue({ + success: true, + status: "connected", + clientId: "publishable-client-id", + }); + + await bootstrapTONE3000Session(); + const result = await startTONE3000InteractiveAuth(); + + expect(result.status).toBe("connected"); + expect(startAuth).toHaveBeenCalledWith({}); + expect(getStatus).toHaveBeenCalledTimes(2); + expect(getTONE3000SessionSnapshot().status).toMatchObject({ + authenticated: true, + hasRefreshToken: true, + }); + }); + + it("reports reconnect after invalid refresh without repeating browser prompts", async () => { + const startAuth = vi.spyOn(nativeBridge, "startTONE3000AuthFlow"); + vi.spyOn(nativeBridge, "getTONE3000AuthStatus").mockResolvedValue({ + success: true, + authenticated: false, + expired: true, + hasRefreshToken: true, + clientId: "client-id", + }); + vi.spyOn(nativeBridge, "refreshTONE3000Auth").mockResolvedValue({ + success: false, + oauthError: "invalid_grant", + error: "Refresh token expired", + }); + + const result = await ensureTONE3000Session("loading the tone"); + + expect(result.ok).toBe(false); + expect(result.message).toContain("Refresh token expired"); + expect(startAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/trackBatchRename.test.ts b/frontend/src/__tests__/trackBatchRename.test.ts new file mode 100644 index 0000000..8fd85cb --- /dev/null +++ b/frontend/src/__tests__/trackBatchRename.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type Track, + type TrackType, + useDAWStore, +} from "../store/useDAWStore"; +import { + buildTrackRenameChanges, + getTrackNameEditKeyAction, + resolveTrackRenameTargetIds, + shouldCommitTrackNameEdit, +} from "../utils/trackRename"; +import trackNameEditorSource from "../components/TrackNameEditor.tsx?raw"; + +const initialState = useDAWStore.getState(); + +function makeTrack(id: string, name: string, type: TrackType = "audio"): Track { + return createDefaultTrack(id, name, "#3b82f6", type); +} + +function resetStore(tracks: Track[] = []) { + commandManager.clear(); + useDAWStore.setState({ + ...initialState, + tracks, + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + isModified: false, + canUndo: false, + canRedo: false, + }); +} + +describe("batch track rename helpers", () => { + const tracks = [ + makeTrack("top", "Top"), + makeTrack("middle", "Middle", "midi"), + makeTrack("bottom", "Bottom", "ai"), + ]; + + it("orders selected targets by their visual project order", () => { + expect( + resolveTrackRenameTargetIds( + tracks, + ["bottom", "top", "middle"], + "bottom", + ), + ).toEqual(["top", "middle", "bottom"]); + }); + + it("targets only an edited track outside the current selection", () => { + expect( + resolveTrackRenameTargetIds(tracks, ["top", "middle"], "bottom"), + ).toEqual(["bottom"]); + }); + + it("builds suffixes in visual order while ignoring duplicates and missing ids", () => { + expect( + buildTrackRenameChanges( + tracks, + ["bottom", "missing", "top", "bottom", "middle"], + "Example", + ), + ).toEqual([ + { id: "top", oldName: "Top", newName: "Example" }, + { id: "middle", oldName: "Middle", newName: "Example 1" }, + { id: "bottom", oldName: "Bottom", newName: "Example 2" }, + ]); + }); + + it("maps Enter and Escape while leaving IME composition untouched", () => { + expect(getTrackNameEditKeyAction("Enter", false)).toBe("commit"); + expect(getTrackNameEditKeyAction("Escape", false)).toBe("cancel"); + expect(getTrackNameEditKeyAction("Enter", true)).toBeNull(); + expect(getTrackNameEditKeyAction("Enter", false, 229)).toBeNull(); + expect(getTrackNameEditKeyAction("a", false)).toBeNull(); + }); + + it("does not commit a focus/blur cycle whose draft never changed", () => { + expect(shouldCommitTrackNameEdit("Snare", "Snare")).toBe(false); + expect(shouldCommitTrackNameEdit("Snare", "Snare 2")).toBe(true); + expect(trackNameEditorSource).toContain( + "if (!shouldCommitTrackNameEdit(initialDraftRef.current, draftRef.current))", + ); + }); +}); + +describe("batch track rename action", () => { + beforeEach(() => resetStore()); + + afterEach(() => resetStore()); + + it("renames mixed track types in one undoable, dirty transaction", () => { + const tracks = [ + makeTrack("audio", "Audio", "audio"), + makeTrack("midi", "MIDI", "midi"), + makeTrack("instrument", "Instrument", "instrument"), + makeTrack("ai", "AI", "ai"), + { ...makeTrack("folder", "Folder", "audio"), isFolder: true }, + makeTrack("bus", "Bus", "bus"), + ]; + resetStore(tracks); + useDAWStore.setState({ + selectedTrackId: "ai", + selectedTrackIds: ["ai", "bus", "midi", "folder", "audio", "instrument"], + lastSelectedTrackId: "ai", + }); + + const state = useDAWStore.getState(); + const targets = resolveTrackRenameTargetIds( + state.tracks, + state.selectedTrackIds, + "ai", + ); + state.renameTracks(targets, "Example"); + + expect(useDAWStore.getState().tracks.map((track) => track.name)).toEqual([ + "Example", + "Example 1", + "Example 2", + "Example 3", + "Example 4", + "Example 5", + ]); + expect(useDAWStore.getState().isModified).toBe(true); + expect(useDAWStore.getState().canUndo).toBe(true); + expect(commandManager.getUndoStack()).toHaveLength(1); + expect(commandManager.getUndoStack()[0].type).toBe("RENAME_TRACKS"); + + // Selection changes after the edit are not part of rename history. + useDAWStore.setState({ + selectedTrackId: "bus", + selectedTrackIds: ["bus"], + lastSelectedTrackId: "bus", + }); + useDAWStore.getState().undo(); + + expect(useDAWStore.getState().tracks.map((track) => track.name)).toEqual([ + "Audio", + "MIDI", + "Instrument", + "AI", + "Folder", + "Bus", + ]); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["bus"]); + expect(useDAWStore.getState().canRedo).toBe(true); + expect(useDAWStore.getState().isModified).toBe(true); + + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.map((track) => track.name)).toEqual([ + "Example", + "Example 1", + "Example 2", + "Example 3", + "Example 4", + "Example 5", + ]); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["bus"]); + }); + + it("renames only an unselected edited track", () => { + const tracks = [ + makeTrack("one", "One"), + makeTrack("two", "Two"), + makeTrack("three", "Three"), + ]; + resetStore(tracks); + useDAWStore.setState({ selectedTrackIds: ["one", "two"] }); + + const state = useDAWStore.getState(); + const targets = resolveTrackRenameTargetIds( + state.tracks, + state.selectedTrackIds, + "three", + ); + state.renameTracks(targets, "Solo"); + + expect(useDAWStore.getState().tracks.map((track) => track.name)).toEqual([ + "One", + "Two", + "Solo", + ]); + expect(commandManager.getUndoStack()).toHaveLength(1); + }); + + it("does not create history or dirty the project for a no-op", () => { + resetStore([makeTrack("same", "Same")]); + + useDAWStore.getState().renameTracks(["same", "same", "missing"], "Same"); + + expect(commandManager.getUndoStack()).toHaveLength(0); + expect(useDAWStore.getState().canUndo).toBe(false); + expect(useDAWStore.getState().isModified).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/transportRecording.test.ts b/frontend/src/__tests__/transportRecording.test.ts index 303b773..0091c9e 100644 --- a/frontend/src/__tests__/transportRecording.test.ts +++ b/frontend/src/__tests__/transportRecording.test.ts @@ -2,6 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { nativeBridge } from "../services/NativeBridge"; import { createDefaultTrack, type Track, useDAWStore } from "../store/useDAWStore"; import transportBarSource from "../components/TransportBar.tsx?raw"; +import { dispatchGlobalShortcut } from "../utils/globalShortcutDispatcher"; +import { + activateShortcutContext, + registerShortcutSurface, + resetShortcutContextForTests, +} from "../utils/shortcutContext"; const initialState = useDAWStore.getState(); @@ -44,6 +50,7 @@ describe("transport recording", () => { }); afterEach(() => { + resetShortcutContextForTests(); vi.restoreAllMocks(); useDAWStore.setState(initialState); }); @@ -54,6 +61,65 @@ describe("transport recording", () => { expect(transportBarSource).not.toContain("await record();"); }); + it("does not resume a pending play request after stop", async () => { + let releaseSync!: () => void; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const syncClipsWithBackend = vi.fn(() => syncGate); + vi.spyOn(nativeBridge, "hasAnyActiveARA").mockResolvedValue(false); + useDAWStore.setState({ + syncClipsWithBackend, + transport: { + ...useDAWStore.getState().transport, + isPlaying: false, + isPaused: false, + isRecording: false, + }, + }); + + const playRequest = useDAWStore.getState().play(); + await vi.waitFor(() => expect(syncClipsWithBackend).toHaveBeenCalledTimes(1)); + await useDAWStore.getState().stop(); + releaseSync(); + await playRequest; + + expect(useDAWStore.getState().transport.isPlaying).toBe(false); + expect(vi.mocked(nativeBridge.setTransportPlaying).mock.calls) + .not.toContainEqual([true]); + }); + + it("does not resume a pending play request after pause", async () => { + let releaseSync!: () => void; + const syncGate = new Promise((resolve) => { + releaseSync = resolve; + }); + const syncClipsWithBackend = vi.fn(() => syncGate); + vi.spyOn(nativeBridge, "hasAnyActiveARA").mockResolvedValue(false); + useDAWStore.setState({ + syncClipsWithBackend, + transport: { + ...useDAWStore.getState().transport, + isPlaying: false, + isPaused: false, + isRecording: false, + }, + }); + + const playRequest = useDAWStore.getState().play(); + await vi.waitFor(() => expect(syncClipsWithBackend).toHaveBeenCalledTimes(1)); + useDAWStore.getState().pause(); + releaseSync(); + await playRequest; + + expect(useDAWStore.getState().transport).toMatchObject({ + isPlaying: false, + isPaused: true, + }); + expect(vi.mocked(nativeBridge.setTransportPlaying).mock.calls) + .not.toContainEqual([true]); + }); + it("punches in at the playhead captured when record is pressed", async () => { const calls: string[] = []; vi.mocked(nativeBridge.setTransportRecording).mockImplementation(async (recording) => { @@ -109,7 +175,28 @@ describe("transport recording", () => { expect(stop).toHaveBeenCalled(); }); - it("selects newly completed recorded clips after stop", async () => { + it("uses the full stop path when Play/Pause is invoked during recording", async () => { + const stop = vi.fn().mockResolvedValue(undefined); + const pause = vi.fn(); + useDAWStore.setState({ + stop, + pause, + recordSession: { id: "record-session", startTime: 3, trackIds: ["track-midi"] }, + transport: { + ...useDAWStore.getState().transport, + isPlaying: true, + isPaused: false, + isRecording: true, + }, + }); + + await useDAWStore.getState().togglePlayPause(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(pause).not.toHaveBeenCalled(); + }); + + it("Space stops and finalizes audio and MIDI takes from the active Timeline", async () => { const audioTrack = { ...createDefaultTrack("track-audio", "Audio", "#f97316", "audio"), armed: true, @@ -151,10 +238,38 @@ describe("transport recording", () => { currentTime: 5, }, playStartPosition: 2, + recordSession: { + id: "space-stop-record-session", + startTime: 2, + trackIds: [audioTrack.id, midiTrack.id], + }, + recordingClips: [ + { trackId: audioTrack.id, startTime: 2 }, + { trackId: midiTrack.id, startTime: 2 }, + ], syncMIDITrackToBackend: vi.fn().mockResolvedValue(undefined), }); - await useDAWStore.getState().stop(); + registerShortcutSurface({ kind: "timeline" }, () => "unmatched"); + activateShortcutContext({ kind: "timeline" }); + + expect(dispatchGlobalShortcut({ + key: " ", + code: "Space", + source: "browser", + }, "windows")).toBe(true); + + await vi.waitFor(() => { + expect(nativeBridge.getLastCompletedClips).toHaveBeenCalledTimes(1); + expect(nativeBridge.getLastCompletedMIDIClips).toHaveBeenCalledTimes(1); + }); + await vi.waitFor(() => { + const completedState = useDAWStore.getState(); + expect(completedState.tracks.find((track) => track.id === audioTrack.id)?.clips) + .toHaveLength(1); + expect(completedState.tracks.find((track) => track.id === midiTrack.id)?.midiClips) + .toHaveLength(1); + }); const state = useDAWStore.getState(); const recordedAudioClip = state.tracks.find((track) => track.id === audioTrack.id)?.clips[0]; @@ -167,6 +282,15 @@ describe("transport recording", () => { expect(state.selectedTrackId).toBeNull(); expect(state.selectedTrackIds).toEqual([]); expect(state.lastSelectedTrackId).toBeNull(); + expect(state.transport).toMatchObject({ + isPlaying: false, + isPaused: false, + isRecording: false, + }); + expect(state.recordSession).toBeNull(); + expect(state.recordingClips).toEqual([]); + expect(nativeBridge.setTransportPlaying).toHaveBeenCalledWith(false); + expect(nativeBridge.setTransportRecording).toHaveBeenCalledWith(false); }); it("finalizes completed clips when transport sync cleared isRecording but a record session remains", async () => { diff --git a/frontend/src/__tests__/undoableDAWMutations.test.ts b/frontend/src/__tests__/undoableDAWMutations.test.ts new file mode 100644 index 0000000..cdb2a71 --- /dev/null +++ b/frontend/src/__tests__/undoableDAWMutations.test.ts @@ -0,0 +1,989 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nativeBridge } from "../services/NativeBridge"; +import { commandManager } from "../store/commands"; +import { + createDefaultTrack, + type AudioClip, + type AutomationLane, + type Track, + useDAWStore, +} from "../store/useDAWStore"; +import { createNAMPreviewMonitorLease } from "../utils/trackMonitorOwnership"; + +const originalState = useDAWStore.getState(); + +function audioClip(id: string, overrides: Partial = {}): AudioClip { + return { + id, + name: id, + filePath: `C:/audio/${id}.wav`, + startTime: 0, + duration: 2, + offset: 0, + color: "#111111", + volumeDB: 0, + fadeIn: 0, + fadeOut: 0, + ...overrides, + }; +} + +function track(id: string, overrides: Partial = {}): Track { + return { + ...createDefaultTrack(id, id, "#111111", "audio", []), + ...overrides, + }; +} + +function volumeLane(overrides: Partial = {}): AutomationLane { + return { + id: "volume-lane", + param: "volume", + points: [], + visible: true, + mode: "read", + armed: false, + readEnabled: true, + ...overrides, + }; +} + +function currentTrack(id: string): Track { + const value = useDAWStore.getState().tracks.find((candidate) => candidate.id === id); + if (!value) throw new Error(`Missing test track ${id}`); + return value; +} + +beforeEach(() => { + commandManager.clear(); + useDAWStore.setState({ + tracks: [], + trackGroups: [], + selectedTrackId: null, + selectedTrackIds: [], + lastSelectedTrackId: null, + selectedClipId: null, + selectedClipIds: [], + automatedParamValues: {}, + isMasterMuted: false, + masterMono: false, + masterVolume: 0.8, + masterPan: 0, + masterAutomationLanes: [], + masterAutomationReadEnabled: false, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: false, + canUndo: false, + canRedo: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + commandManager.clear(); + useDAWStore.setState(originalState); +}); + +describe("undo-aware track control mutations", () => { + it("coalesces AI parameter packets into one exact UPDATE_TRACK undo/redo edit", () => { + useDAWStore.setState({ + tracks: [track("ai", { + type: "ai", + aiWorkflow: "text-to-music", + aiWorkflowParams: { bpm: 120 }, + })], + }); + const state = useDAWStore.getState(); + expect(state.beginAITrackParamsEdit("ai")).toBe(true); + state.setAITrackParams("ai", { ...currentTrack("ai").aiWorkflowParams, bpm: 121 }); + state.setAITrackParams("ai", { ...currentTrack("ai").aiWorkflowParams, bpm: 122 }); + state.setAITrackParams("ai", { ...currentTrack("ai").aiWorkflowParams, bpm: 123 }); + expect(state.commitAITrackParamsEdit("ai")).toBe(true); + expect(state.commitAITrackParamsEdit("ai")).toBe(false); + expect(currentTrack("ai").aiWorkflowParams?.bpm).toBe(123); + + useDAWStore.getState().undo(); + expect(currentTrack("ai").aiWorkflowParams).toEqual({ bpm: 120 }); + expect(useDAWStore.getState().canUndo).toBe(false); + useDAWStore.getState().redo(); + expect(currentTrack("ai").aiWorkflowParams?.bpm).toBe(123); + }); + + it("does not create AI parameter commands for invalid or no-op sessions", () => { + useDAWStore.setState({ + tracks: [track("ai", { + type: "ai", + aiWorkflow: "text-to-music", + aiWorkflowParams: { bpm: 120 }, + })], + }); + const state = useDAWStore.getState(); + expect(state.beginAITrackParamsEdit("missing")).toBe(false); + expect(state.beginAITrackParamsEdit("ai")).toBe(true); + expect(state.commitAITrackParamsEdit("ai")).toBe(false); + expect(useDAWStore.getState().canUndo).toBe(false); + }); + + it("adjusts a selected linked fader group as one exact undo/redo transaction", () => { + vi.spyOn(nativeBridge, "setTrackVolume").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [ + track("a", { volumeDB: 0, volume: 1 }), + track("b", { volumeDB: -6, volume: Math.pow(10, -6 / 20) }), + track("outside", { volumeDB: -12, volume: Math.pow(10, -12 / 20) }), + ], + selectedTrackId: "a", + selectedTrackIds: ["a"], + trackGroups: [{ + id: "volume-group", + name: "Linked volume", + leadTrackId: "a", + memberTrackIds: ["a", "b"], + linkedParams: ["volume"], + }], + }); + + const state = useDAWStore.getState(); + expect(state.beginTrackVolumeBatchEdit(state.selectedTrackIds)).toBe(true); + expect(state.adjustTrackVolumeBatch(1)).toBe(true); + expect(state.commitTrackVolumeBatchEdit()).toBe(true); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.volumeDB)) + .toEqual([1, -5, -12]); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.volumeDB)) + .toEqual([0, -6, -12]); + expect(useDAWStore.getState().canUndo).toBe(false); + + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.volumeDB)) + .toEqual([1, -5, -12]); + }); + + it("deduplicates all-track linked members and keeps frozen tracks eligible", () => { + vi.spyOn(nativeBridge, "setTrackVolume").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [ + track("a", { volumeDB: 0 }), + track("b", { volumeDB: -6 }), + track("frozen", { volumeDB: -12, frozen: true }), + ], + trackGroups: [{ + id: "volume-group", + name: "Linked volume", + leadTrackId: "a", + memberTrackIds: ["a", "b"], + linkedParams: ["volume"], + }], + }); + + const state = useDAWStore.getState(); + expect(state.beginTrackVolumeBatchEdit(["a", "b", "frozen", "a"])).toBe(true); + expect(state.adjustTrackVolumeBatch(-0.5)).toBe(true); + expect(state.commitTrackVolumeBatchEdit()).toBe(true); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.volumeDB)) + .toEqual([-0.5, -6.5, -12.5]); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.volumeDB)) + .toEqual([0, -6, -12]); + }); + + it("does not create batch-volume undo entries for empty, invalid, or clamped no-op edits", () => { + useDAWStore.setState({ tracks: [track("ceiling", { volumeDB: 12 })] }); + const state = useDAWStore.getState(); + + expect(state.beginTrackVolumeBatchEdit([])).toBe(false); + expect(state.adjustTrackVolumeBatch(1)).toBe(false); + expect(state.commitTrackVolumeBatchEdit()).toBe(false); + expect(state.beginTrackVolumeBatchEdit(["missing"])).toBe(false); + expect(state.beginTrackVolumeBatchEdit(["ceiling"])).toBe(true); + expect(state.adjustTrackVolumeBatch(Number.NaN)).toBe(false); + expect(state.adjustTrackVolumeBatch(1)).toBe(false); + expect(state.commitTrackVolumeBatchEdit()).toBe(false); + expect(useDAWStore.getState().canUndo).toBe(false); + }); + + it("arms linked tracks, skips record-safe members, and restores the exact prior states", async () => { + vi.spyOn(nativeBridge, "setTrackRecordArm").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [ + track("a", { armed: false }), + track("b", { armed: false, recordSafe: true }), + ], + trackGroups: [{ + id: "armed-group", + name: "Armed", + leadTrackId: "a", + memberTrackIds: ["a", "b"], + linkedParams: ["armed"], + }], + }); + + await useDAWStore.getState().toggleTrackArmed("a"); + expect(currentTrack("a").armed).toBe(true); + expect(currentTrack("b").armed).toBe(false); + + useDAWStore.getState().undo(); + expect(currentTrack("a").armed).toBe(false); + expect(currentTrack("b").armed).toBe(false); + + useDAWStore.getState().redo(); + expect(currentTrack("a").armed).toBe(true); + expect(currentTrack("b").armed).toBe(false); + }); + + it("restores mixed linked FX bypass states on undo", async () => { + vi.spyOn(nativeBridge, "bypassTrackInputFX").mockResolvedValue(true); + vi.spyOn(nativeBridge, "bypassTrackFX").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [ + track("a", { fxBypassed: false, inputFxCount: 1, trackFxCount: 1 }), + track("b", { fxBypassed: true, inputFxCount: 1, trackFxCount: 1 }), + ], + trackGroups: [{ + id: "fx-group", + name: "FX", + leadTrackId: "a", + memberTrackIds: ["a", "b"], + linkedParams: ["fxBypass"], + }], + }); + + await useDAWStore.getState().toggleTrackFXBypass("a"); + expect([currentTrack("a").fxBypassed, currentTrack("b").fxBypassed]).toEqual([true, true]); + + useDAWStore.getState().undo(); + expect([currentTrack("a").fxBypassed, currentTrack("b").fxBypassed]).toEqual([false, true]); + + useDAWStore.getState().redo(); + expect([currentTrack("a").fxBypassed, currentTrack("b").fxBypassed]).toEqual([true, true]); + }); + + it("undoes and redoes monitoring and phase inversion with native synchronization", async () => { + const monitorSpy = vi.spyOn(nativeBridge, "setTrackInputMonitoring").mockResolvedValue(true); + const phaseSpy = vi.spyOn(nativeBridge, "setTrackPhaseInvert").mockResolvedValue(true); + useDAWStore.setState({ tracks: [track("a", { monitorEnabled: false, phaseInverted: false })] }); + + await useDAWStore.getState().toggleTrackMonitor("a"); + expect(currentTrack("a").monitorEnabled).toBe(true); + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(currentTrack("a").monitorEnabled).toBe(false)); + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(currentTrack("a").monitorEnabled).toBe(true)); + expect(monitorSpy).toHaveBeenCalledWith("a", false); + + commandManager.clear(); + await useDAWStore.getState().setTrackPhaseInvert("a", true); + expect(currentTrack("a").phaseInverted).toBe(true); + useDAWStore.getState().undo(); + expect(currentTrack("a").phaseInverted).toBe(false); + useDAWStore.getState().redo(); + expect(currentTrack("a").phaseInverted).toBe(true); + expect(phaseSpy).toHaveBeenCalledWith("a", false); + }); + + it("keeps preview-only monitoring out of project dirty and undo state and surfaces native failure", async () => { + const monitorSpy = vi.spyOn(nativeBridge, "setTrackInputMonitoring").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [track("preview", { monitorEnabled: false })], + isModified: false, + canUndo: false, + canRedo: false, + }); + + await expect(useDAWStore.getState().setTrackMonitorTransient("preview", true)).resolves.toBe(true); + expect(currentTrack("preview").monitorEnabled).toBe(true); + expect(useDAWStore.getState()).toMatchObject({ isModified: false, canUndo: false, canRedo: false }); + + monitorSpy.mockResolvedValueOnce(false); + await expect(useDAWStore.getState().setTrackMonitorTransient("preview", false)).resolves.toBe(false); + expect(currentTrack("preview").monitorEnabled).toBe(true); + expect(useDAWStore.getState()).toMatchObject({ isModified: false, canUndo: false, canRedo: false }); + }); + + it("leaves direct monitoring state, dirty state, and undo history unchanged on native failure", async () => { + const monitorSpy = vi.spyOn(nativeBridge, "setTrackInputMonitoring"); + useDAWStore.setState({ + tracks: [track("monitor-failure", { monitorEnabled: false })], + isModified: false, + canUndo: false, + canRedo: false, + }); + + monitorSpy.mockResolvedValueOnce(false); + await expect(useDAWStore.getState().toggleTrackMonitor("monitor-failure")) + .rejects.toThrow("Native track monitoring rejected enable"); + expect(currentTrack("monitor-failure").monitorEnabled).toBe(false); + expect(useDAWStore.getState()).toMatchObject({ isModified: false, canUndo: false, canRedo: false }); + expect(commandManager.canUndo()).toBe(false); + + monitorSpy.mockRejectedValueOnce(new Error("monitor bridge unavailable")); + await expect(useDAWStore.getState().toggleTrackMonitor("monitor-failure")) + .rejects.toThrow("monitor bridge unavailable"); + expect(currentTrack("monitor-failure").monitorEnabled).toBe(false); + expect(useDAWStore.getState()).toMatchObject({ isModified: false, canUndo: false, canRedo: false }); + expect(commandManager.canUndo()).toBe(false); + }); + + it("retains preview monitor ownership when a user disable request fails", async () => { + const monitorSpy = vi.spyOn(nativeBridge, "setTrackInputMonitoring").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [track("preview-owner", { monitorEnabled: false })], + isModified: false, + canUndo: false, + canRedo: false, + }); + const lease = createNAMPreviewMonitorLease({ + read: (trackId) => useDAWStore.getState().tracks.find((entry) => entry.id === trackId)?.monitorEnabled, + setTransient: (trackId, enabled) => useDAWStore.getState().setTrackMonitorTransient(trackId, enabled), + }); + + await expect(lease.ensureEnabled("preview-owner")).resolves.toBe(true); + expect(currentTrack("preview-owner").monitorEnabled).toBe(true); + + monitorSpy.mockResolvedValueOnce(false); + await expect(useDAWStore.getState().toggleTrackMonitor("preview-owner")) + .rejects.toThrow("Native track monitoring rejected disable"); + expect(currentTrack("preview-owner").monitorEnabled).toBe(true); + expect(lease.ownsTemporaryEnable()).toBe(true); + + monitorSpy.mockResolvedValueOnce(true); + await expect(lease.release()).resolves.toBe(true); + expect(currentTrack("preview-owner").monitorEnabled).toBe(false); + expect(useDAWStore.getState()).toMatchObject({ isModified: false, canUndo: false, canRedo: false }); + }); + + it.each(["false", "reject"] as const)( + "rolls back a selected-track monitoring batch on native %s without stealing preview ownership", + async (failureMode) => { + const monitorSpy = vi.spyOn(nativeBridge, "setTrackInputMonitoring").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [ + track("batch-preview", { monitorEnabled: false }), + track("batch-other", { monitorEnabled: false }), + ], + selectedTrackId: "batch-preview", + selectedTrackIds: ["batch-preview", "batch-other"], + lastSelectedTrackId: "batch-other", + isModified: false, + canUndo: false, + canRedo: false, + }); + const lease = createNAMPreviewMonitorLease({ + read: (trackId) => useDAWStore.getState().tracks.find((entry) => entry.id === trackId)?.monitorEnabled, + setTransient: (trackId, enabled) => useDAWStore.getState().setTrackMonitorTransient(trackId, enabled), + }); + + await expect(lease.ensureEnabled("batch-preview")).resolves.toBe(true); + monitorSpy.mockClear(); + monitorSpy.mockImplementation(async (trackId, enabled) => { + if (trackId === "batch-other" && enabled) { + if (failureMode === "reject") throw new Error("monitor bridge unavailable"); + return false; + } + return true; + }); + + await expect(useDAWStore.getState().toggleSelectedTracksMonitor()).resolves.toBe(false); + expect(monitorSpy.mock.calls).toEqual([ + ["batch-preview", false], + ["batch-other", true], + ["batch-preview", true], + ]); + expect(currentTrack("batch-preview").monitorEnabled).toBe(true); + expect(currentTrack("batch-other").monitorEnabled).toBe(false); + expect(useDAWStore.getState()).toMatchObject({ isModified: false, canUndo: false, canRedo: false }); + expect(commandManager.canUndo()).toBe(false); + expect(lease.ownsTemporaryEnable()).toBe(true); + + await expect(lease.release()).resolves.toBe(true); + expect(currentTrack("batch-preview").monitorEnabled).toBe(false); + }, + ); + + it("commits a selected-track monitoring batch only after every native update succeeds", async () => { + const monitorSpy = vi.spyOn(nativeBridge, "setTrackInputMonitoring").mockResolvedValue(true); + useDAWStore.setState({ + tracks: [ + track("batch-a", { monitorEnabled: false }), + track("batch-b", { monitorEnabled: true }), + ], + selectedTrackId: "batch-a", + selectedTrackIds: ["batch-a", "batch-b"], + lastSelectedTrackId: "batch-b", + isModified: false, + canUndo: false, + canRedo: false, + }); + + await expect(useDAWStore.getState().toggleSelectedTracksMonitor()).resolves.toBe(true); + expect([currentTrack("batch-a").monitorEnabled, currentTrack("batch-b").monitorEnabled]) + .toEqual([true, false]); + expect(useDAWStore.getState()).toMatchObject({ isModified: true, canUndo: true, canRedo: false }); + + useDAWStore.getState().undo(); + await vi.waitFor(() => { + expect([currentTrack("batch-a").monitorEnabled, currentTrack("batch-b").monitorEnabled]) + .toEqual([false, true]); + }); + useDAWStore.getState().redo(); + await vi.waitFor(() => { + expect([currentTrack("batch-a").monitorEnabled, currentTrack("batch-b").monitorEnabled]) + .toEqual([true, false]); + }); + expect(monitorSpy).toHaveBeenCalledTimes(6); + }); + + it("serializes rapid monitor toggles so each command reads the accepted state", async () => { + const monitorSpy = vi.spyOn(nativeBridge, "setTrackInputMonitoring"); + let acceptFirst!: (accepted: boolean) => void; + monitorSpy + .mockReturnValueOnce(new Promise((resolve) => { acceptFirst = resolve; })) + .mockResolvedValue(true); + useDAWStore.setState({ + tracks: [track("rapid-monitor", { monitorEnabled: false })], + isModified: false, + canUndo: false, + canRedo: false, + }); + + const firstToggle = useDAWStore.getState().toggleTrackMonitor("rapid-monitor"); + const secondToggle = useDAWStore.getState().toggleTrackMonitor("rapid-monitor"); + await vi.waitFor(() => expect(monitorSpy).toHaveBeenCalledTimes(1)); + expect(monitorSpy).toHaveBeenNthCalledWith(1, "rapid-monitor", true); + + acceptFirst(true); + await Promise.all([firstToggle, secondToggle]); + + expect(monitorSpy.mock.calls).toEqual([ + ["rapid-monitor", true], + ["rapid-monitor", false], + ]); + expect(currentTrack("rapid-monitor").monitorEnabled).toBe(false); + expect(commandManager.getUndoStack()).toHaveLength(2); + }); + + it("serializes a user monitor command behind preview setup without stale-state inversion", async () => { + const monitorSpy = vi.spyOn(nativeBridge, "setTrackInputMonitoring"); + let acceptPreview!: (accepted: boolean) => void; + monitorSpy + .mockReturnValueOnce(new Promise((resolve) => { acceptPreview = resolve; })) + .mockResolvedValue(true); + useDAWStore.setState({ + tracks: [track("preview-race", { monitorEnabled: false })], + isModified: false, + canUndo: false, + canRedo: false, + }); + const lease = createNAMPreviewMonitorLease({ + read: (trackId) => useDAWStore.getState().tracks.find((entry) => entry.id === trackId)?.monitorEnabled, + setTransient: (trackId, enabled) => useDAWStore.getState().setTrackMonitorTransient(trackId, enabled), + }); + + const previewStart = lease.ensureEnabled("preview-race"); + const userToggle = useDAWStore.getState().toggleTrackMonitor("preview-race"); + await vi.waitFor(() => expect(monitorSpy).toHaveBeenCalledTimes(1)); + acceptPreview(true); + await Promise.all([previewStart, userToggle]); + + expect(monitorSpy.mock.calls).toEqual([ + ["preview-race", true], + ["preview-race", false], + ]); + expect(currentTrack("preview-race").monitorEnabled).toBe(false); + expect(lease.ownsTemporaryEnable()).toBe(false); + await expect(lease.release()).resolves.toBe(true); + expect(monitorSpy).toHaveBeenCalledTimes(2); + }); + + it("undoes and redoes automation read/write mode snapshots", () => { + const lane = volumeLane(); + useDAWStore.setState({ + tracks: [track("a", { + automationReadEnabled: true, + automationWriteEnabled: false, + automationEnabled: true, + automationLanes: [lane], + })], + }); + + useDAWStore.getState().toggleTrackAutomationRead("a"); + expect(currentTrack("a")).toMatchObject({ automationReadEnabled: false, automationEnabled: false }); + expect(currentTrack("a").automationLanes[0].mode).toBe("off"); + useDAWStore.getState().undo(); + expect(currentTrack("a")).toMatchObject({ automationReadEnabled: true, automationEnabled: true }); + expect(currentTrack("a").automationLanes[0].mode).toBe("read"); + useDAWStore.getState().redo(); + expect(currentTrack("a").automationReadEnabled).toBe(false); + + commandManager.clear(); + useDAWStore.getState().toggleTrackAutomationWrite("a"); + expect(currentTrack("a")).toMatchObject({ automationReadEnabled: true, automationWriteEnabled: true }); + useDAWStore.getState().undo(); + expect(currentTrack("a")).toMatchObject({ automationReadEnabled: false, automationWriteEnabled: false }); + useDAWStore.getState().redo(); + expect(currentTrack("a")).toMatchObject({ automationReadEnabled: true, automationWriteEnabled: true }); + }); +}); + +describe("undo-aware structural track mutations", () => { + it("duplicates a track as one reversible native/store transaction", async () => { + vi.spyOn(nativeBridge, "addTrack").mockImplementation(async (trackId) => trackId || "mock-track"); + vi.spyOn(nativeBridge, "removeTrack").mockResolvedValue(true); + vi.spyOn(nativeBridge, "closeAllPluginWindows").mockResolvedValue(true); + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([]); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + useDAWStore.setState({ + tracks: [track("source")], + selectedTrackId: "source", + selectedTrackIds: ["source"], + lastSelectedTrackId: "source", + }); + + await useDAWStore.getState().duplicateTrack("source"); + const duplicateId = useDAWStore.getState().tracks.find((candidate) => candidate.id !== "source")?.id; + expect(duplicateId).toBeTruthy(); + expect(useDAWStore.getState().selectedTrackIds).toEqual([duplicateId]); + + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((candidate) => candidate.id)).toEqual(["source"])); + expect(useDAWStore.getState().selectedTrackIds).toEqual(["source"]); + + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(useDAWStore.getState().tracks.map((candidate) => candidate.id)).toEqual(["source", duplicateId])); + expect(useDAWStore.getState().selectedTrackIds).toEqual([duplicateId]); + }); + + it("sets multiple track and clip colors in one undo step", () => { + const midiClip = { + id: "midi", + name: "MIDI", + startTime: 0, + duration: 1, + offset: 0, + color: "#222222", + events: [], + ccEvents: [], + }; + useDAWStore.setState({ + tracks: [ + track("a", { clips: [audioClip("audio")], midiClips: [midiClip] }), + track("b", { color: "#333333" }), + ], + }); + + useDAWStore.getState().setTracksColorWithUndo(["a", "b", "a"], "#abcdef"); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.color)).toEqual(["#abcdef", "#abcdef"]); + expect(currentTrack("a").clips[0].color).toBe("#abcdef"); + expect(currentTrack("a").midiClips[0].color).toBe("#abcdef"); + + useDAWStore.getState().undo(); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.color)).toEqual(["#111111", "#333333"]); + expect(currentTrack("a").clips[0].color).toBe("#111111"); + expect(currentTrack("a").midiClips[0].color).toBe("#222222"); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().tracks.map((candidate) => candidate.color)).toEqual(["#abcdef", "#abcdef"]); + }); + + it("links and unlinks selections atomically", () => { + useDAWStore.setState({ tracks: [track("a"), track("b"), track("c")] }); + + useDAWStore.getState().addTrackGroup("Group", "a", ["a", "b", "b"], ["mute", "solo"]); + expect(useDAWStore.getState().trackGroups[0].memberTrackIds).toEqual(["a", "b"]); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().trackGroups).toEqual([]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().trackGroups).toHaveLength(1); + + commandManager.clear(); + useDAWStore.setState({ + trackGroups: [{ + id: "old-group", + name: "Old", + leadTrackId: "a", + memberTrackIds: ["a", "c"], + linkedParams: ["mute"], + }], + }); + useDAWStore.getState().addTrackGroup("Replacement", "a", ["a", "b"], ["mute"]); + expect(useDAWStore.getState().trackGroups).toHaveLength(1); + expect(useDAWStore.getState().trackGroups[0]).toMatchObject({ + name: "Replacement", + memberTrackIds: ["a", "b"], + }); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().trackGroups[0]).toMatchObject({ + id: "old-group", + memberTrackIds: ["a", "c"], + }); + useDAWStore.getState().redo(); + + commandManager.clear(); + useDAWStore.getState().unlinkTracksFromGroups(["a"]); + expect(useDAWStore.getState().trackGroups).toEqual([]); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().trackGroups[0].memberTrackIds).toEqual(["a", "b"]); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().trackGroups).toEqual([]); + }); + + it("moves and removes multiple tracks from folders in reversible batches", () => { + const folder = track("folder", { isFolder: true }); + useDAWStore.setState({ tracks: [folder, track("a"), track("b")] }); + + useDAWStore.getState().moveTracksToFolder(["a", "b"], "folder"); + expect([currentTrack("a").parentFolderId, currentTrack("b").parentFolderId]).toEqual(["folder", "folder"]); + useDAWStore.getState().undo(); + expect([currentTrack("a").parentFolderId, currentTrack("b").parentFolderId]).toEqual([undefined, undefined]); + useDAWStore.getState().redo(); + expect([currentTrack("a").parentFolderId, currentTrack("b").parentFolderId]).toEqual(["folder", "folder"]); + + commandManager.clear(); + useDAWStore.getState().removeTracksFromFolders(["a", "b"]); + expect([currentTrack("a").parentFolderId, currentTrack("b").parentFolderId]).toEqual([undefined, undefined]); + useDAWStore.getState().undo(); + expect([currentTrack("a").parentFolderId, currentTrack("b").parentFolderId]).toEqual(["folder", "folder"]); + }); + + it("consolidates all audio clips in one undoable replacement", async () => { + vi.spyOn(nativeBridge, "showRenderSaveDialog").mockResolvedValue("C:/renders/consolidated.wav"); + vi.spyOn(nativeBridge, "renderProject").mockResolvedValue(true); + vi.spyOn(nativeBridge, "clearPitchPreviewRoutesForCorrectedSources").mockResolvedValue(0); + const syncClipsWithBackend = vi.fn().mockResolvedValue(undefined); + useDAWStore.setState({ + tracks: [track("a", { + clips: [ + audioClip("one", { startTime: 1, duration: 2 }), + audioClip("two", { startTime: 4, duration: 1 }), + ], + })], + selectedClipId: "one", + selectedClipIds: ["one", "two"], + syncClipsWithBackend, + }); + + await useDAWStore.getState().consolidateTrack("a"); + const consolidatedId = currentTrack("a").clips[0].id; + expect(currentTrack("a").clips).toHaveLength(1); + expect(currentTrack("a").clips[0]).toMatchObject({ startTime: 1, duration: 4 }); + + useDAWStore.getState().undo(); + expect(currentTrack("a").clips.map((clip) => clip.id)).toEqual(["one", "two"]); + expect(useDAWStore.getState().selectedClipIds).toEqual(["one", "two"]); + useDAWStore.getState().redo(); + expect(currentTrack("a").clips.map((clip) => clip.id)).toEqual([consolidatedId]); + }); + + it("re-establishes native freeze state when freeze and unfreeze commands are redone", async () => { + const freezeSpy = vi.spyOn(nativeBridge, "freezeTrack").mockResolvedValue({ + success: true, + filePath: "C:/renders/frozen.wav", + startTime: 0, + duration: 2, + sampleRate: 48_000, + }); + const unfreezeSpy = vi.spyOn(nativeBridge, "unfreezeTrack").mockResolvedValue(true); + useDAWStore.setState({ tracks: [track("a", { clips: [audioClip("source")] })] }); + + useDAWStore.getState().freezeTrack("a"); + await vi.waitFor(() => expect(currentTrack("a").frozen).toBe(true)); + expect(freezeSpy).toHaveBeenCalledTimes(1); + useDAWStore.getState().undo(); + expect(currentTrack("a").frozen).toBe(false); + useDAWStore.getState().redo(); + expect(currentTrack("a").frozen).toBe(true); + await vi.waitFor(() => expect(freezeSpy).toHaveBeenCalledTimes(2)); + + commandManager.clear(); + useDAWStore.getState().unfreezeTrack("a"); + await vi.waitFor(() => expect(currentTrack("a").frozen).toBe(false)); + useDAWStore.getState().undo(); + expect(currentTrack("a").frozen).toBe(true); + await vi.waitFor(() => expect(freezeSpy).toHaveBeenCalledTimes(3)); + useDAWStore.getState().redo(); + expect(currentTrack("a").frozen).toBe(false); + expect(unfreezeSpy).toHaveBeenCalled(); + }); +}); + +describe("undo-aware master and fade-shape mutations", () => { + it("commits master volume and pan edits once and restores the backend on undo/redo", async () => { + const volumeSpy = vi.spyOn(nativeBridge, "setMasterVolume").mockResolvedValue(true); + const panSpy = vi.spyOn(nativeBridge, "setMasterPan").mockResolvedValue(true); + + useDAWStore.getState().beginMasterVolumeEdit(); + await useDAWStore.getState().setMasterVolume(0.5); + await useDAWStore.getState().setMasterVolume(0.25); + useDAWStore.getState().commitMasterVolumeEdit(); + expect(useDAWStore.getState().masterVolume).toBe(0.25); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().masterVolume).toBe(0.8); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().masterVolume).toBe(0.25); + expect(volumeSpy).toHaveBeenCalledWith(0.8); + + commandManager.clear(); + useDAWStore.getState().beginMasterPanEdit(); + await useDAWStore.getState().setMasterPan(0.4); + useDAWStore.getState().commitMasterPanEdit(); + expect(useDAWStore.getState().masterPan).toBe(0.4); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().masterPan).toBe(0); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().masterPan).toBe(0.4); + expect(panSpy).toHaveBeenCalledWith(0); + }); + + it("does not create master control undo entries for no-op edits", () => { + useDAWStore.getState().beginMasterVolumeEdit(); + useDAWStore.getState().commitMasterVolumeEdit(); + useDAWStore.getState().beginMasterPanEdit(); + useDAWStore.getState().commitMasterPanEdit(); + expect(useDAWStore.getState().canUndo).toBe(false); + }); + + it("undoes and redoes master automation read/write mode snapshots", () => { + useDAWStore.setState({ + masterAutomationReadEnabled: true, + masterAutomationWriteEnabled: false, + masterAutomationEnabled: true, + masterAutomationLanes: [volumeLane()], + }); + + useDAWStore.getState().toggleMasterAutomationRead(); + expect(useDAWStore.getState().masterAutomationReadEnabled).toBe(false); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().masterAutomationReadEnabled).toBe(true); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().masterAutomationReadEnabled).toBe(false); + + commandManager.clear(); + useDAWStore.getState().toggleMasterAutomationWrite(); + expect(useDAWStore.getState().masterAutomationWriteEnabled).toBe(true); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().masterAutomationWriteEnabled).toBe(false); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().masterAutomationWriteEnabled).toBe(true); + }); + + it("undoes and redoes master mute and mono", () => { + const volumeSpy = vi.spyOn(nativeBridge, "setMasterVolume").mockResolvedValue(true); + const monoSpy = vi.spyOn(nativeBridge, "setMasterMono").mockResolvedValue(true); + + useDAWStore.getState().toggleMasterMute(); + expect(useDAWStore.getState().isMasterMuted).toBe(true); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().isMasterMuted).toBe(false); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().isMasterMuted).toBe(true); + expect(volumeSpy).toHaveBeenCalledWith(0.8); + + commandManager.clear(); + useDAWStore.getState().toggleMasterMono(); + expect(useDAWStore.getState().masterMono).toBe(true); + useDAWStore.getState().undo(); + expect(useDAWStore.getState().masterMono).toBe(false); + useDAWStore.getState().redo(); + expect(useDAWStore.getState().masterMono).toBe(true); + expect(monoSpy).toHaveBeenCalledWith(false); + }); + + it("tracks fade-in and fade-out shape changes through undo and redo", () => { + useDAWStore.setState({ + tracks: [track("a", { clips: [audioClip("clip", { fadeInShape: 1, fadeOutShape: 2 })] })], + }); + + useDAWStore.getState().setClipFadeInShape("clip", 4); + expect(currentTrack("a").clips[0].fadeInShape).toBe(4); + useDAWStore.getState().undo(); + expect(currentTrack("a").clips[0].fadeInShape).toBe(1); + useDAWStore.getState().redo(); + expect(currentTrack("a").clips[0].fadeInShape).toBe(4); + + commandManager.clear(); + useDAWStore.getState().setClipFadeOutShape("clip", 3); + expect(currentTrack("a").clips[0].fadeOutShape).toBe(3); + useDAWStore.getState().undo(); + expect(currentTrack("a").clips[0].fadeOutShape).toBe(2); + useDAWStore.getState().redo(); + expect(currentTrack("a").clips[0].fadeOutShape).toBe(3); + }); +}); + +describe("undo-aware per-slot FX bypass", () => { + it("sets track FX bypass and restores the native slot on undo/redo", async () => { + useDAWStore.setState({ tracks: [track("a")] }); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([{ + index: 0, + name: "Compressor", + bypassed: false, + }]); + const bypassSpy = vi.spyOn(nativeBridge, "bypassTrackFX").mockResolvedValue(true); + + await expect(useDAWStore.getState().setFXSlotBypassedWithUndo("a", 0, "track", true)) + .resolves.toBe(true); + useDAWStore.getState().undo(); + useDAWStore.getState().redo(); + + expect(bypassSpy.mock.calls).toEqual([ + ["a", 0, true], + ["a", 0, false], + ["a", 0, true], + ]); + }); + + it("sets input FX bypass and restores the native slot on undo/redo", async () => { + useDAWStore.setState({ tracks: [track("a")] }); + vi.spyOn(nativeBridge, "getTrackInputFX").mockResolvedValue([{ + index: 0, + name: "Gate", + bypassed: true, + }]); + const bypassSpy = vi.spyOn(nativeBridge, "bypassTrackInputFX").mockResolvedValue(true); + + await expect(useDAWStore.getState().setFXSlotBypassedWithUndo("a", 0, "input", false)) + .resolves.toBe(true); + useDAWStore.getState().undo(); + useDAWStore.getState().redo(); + + expect(bypassSpy.mock.calls).toEqual([ + ["a", 0, false], + ["a", 0, true], + ["a", 0, false], + ]); + }); + + it("toggles master FX bypass through the same central API", async () => { + vi.spyOn(nativeBridge, "getMasterFX").mockResolvedValue([{ + index: 0, + name: "Limiter", + bypassed: false, + }]); + const bypassSpy = vi.spyOn(nativeBridge, "bypassMasterFX").mockResolvedValue(true); + + await expect(useDAWStore.getState().toggleFXSlotBypassWithUndo("master", 0, "master")) + .resolves.toBe(true); + useDAWStore.getState().undo(); + useDAWStore.getState().redo(); + + expect(bypassSpy.mock.calls).toEqual([ + [0, true], + [0, false], + [0, true], + ]); + }); + + it("rejects invalid or missing slots without creating undo history", async () => { + useDAWStore.setState({ tracks: [track("a")] }); + vi.spyOn(nativeBridge, "getTrackFX").mockResolvedValue([]); + + await expect(useDAWStore.getState().setFXSlotBypassedWithUndo("a", 3, "track", true)) + .resolves.toBe(false); + expect(commandManager.canUndo()).toBe(false); + }); +}); + +describe("undo-aware master FX removal", () => { + it.each([ + { + label: "built-in", + pluginType: "builtin", + pluginName: "OpenStudio Limiter", + pluginReference: "OpenStudio Limiter", + addKind: "builtin" as const, + bypassed: true, + precisionOverride: "float32" as const, + }, + { + label: "S13FX", + pluginType: "s13fx", + pluginName: "Transient Designer", + pluginReference: "C:/effects/transient.jsfx", + addKind: "s13fx" as const, + bypassed: false, + precisionOverride: "auto" as const, + }, + { + label: "hosted plug-in", + pluginType: "clap", + pluginName: "Studio Compressor", + pluginReference: "C:/plugins/compressor.clap", + addKind: "hosted" as const, + bypassed: true, + precisionOverride: "auto" as const, + }, + ])("restores a removed $label slot with state, flags, order, undo, and redo", async ({ + pluginType, + pluginName, + pluginReference, + addKind, + bypassed, + precisionOverride, + }) => { + const target = { + index: 1, + name: pluginName, + type: pluginType, + pluginPath: pluginReference, + bypassed, + precisionOverride, + }; + const getMasterFXSpy = vi.spyOn(nativeBridge, "getMasterFX") + .mockResolvedValueOnce([ + { index: 0, name: "Before" }, + target, + { index: 2, name: "After" }, + ]) + .mockResolvedValueOnce([ + { index: 0, name: "Before" }, + { index: 1, name: "After" }, + { ...target, index: 2 }, + ]); + const savedState = `base64-${pluginType}`; + vi.spyOn(nativeBridge, "getMasterPluginState").mockResolvedValue(savedState); + const removeSpy = vi.spyOn(nativeBridge, "removeMasterFX").mockResolvedValue(true); + const addSpies = { + builtin: vi.spyOn(nativeBridge, "addMasterBuiltInFX").mockResolvedValue(true), + s13fx: vi.spyOn(nativeBridge, "addMasterS13FX").mockResolvedValue(true), + hosted: vi.spyOn(nativeBridge, "addMasterFX").mockResolvedValue(true), + }; + const stateSpy = vi.spyOn(nativeBridge, "setMasterPluginState").mockResolvedValue(true); + const bypassSpy = vi.spyOn(nativeBridge, "bypassMasterFX").mockResolvedValue(true); + const precisionSpy = vi.spyOn(nativeBridge, "setMasterFXPrecisionOverride").mockResolvedValue(true); + const reorderSpy = vi.spyOn(nativeBridge, "reorderMasterFX").mockResolvedValue(true); + + await expect(useDAWStore.getState().removeMasterFXWithUndo(1)).resolves.toBe(true); + expect(removeSpy).toHaveBeenCalledWith(1); + expect(commandManager.canUndo()).toBe(true); + + useDAWStore.getState().undo(); + await vi.waitFor(() => expect(reorderSpy).toHaveBeenCalledWith(2, 1)); + + expect(addSpies[addKind]).toHaveBeenCalledWith(pluginReference); + expect(stateSpy).toHaveBeenCalledWith(2, savedState); + expect(bypassSpy).toHaveBeenCalledWith(2, bypassed); + expect(precisionSpy).toHaveBeenCalledWith(2, precisionOverride); + expect(getMasterFXSpy).toHaveBeenCalledTimes(2); + expect(stateSpy.mock.invocationCallOrder[0]).toBeLessThan(reorderSpy.mock.invocationCallOrder[0]); + expect(bypassSpy.mock.invocationCallOrder[0]).toBeLessThan(reorderSpy.mock.invocationCallOrder[0]); + expect(precisionSpy.mock.invocationCallOrder[0]).toBeLessThan(reorderSpy.mock.invocationCallOrder[0]); + + useDAWStore.getState().redo(); + await vi.waitFor(() => expect(removeSpy).toHaveBeenCalledTimes(2)); + expect(removeSpy.mock.calls).toEqual([[1], [1]]); + }); + + it("rejects a master slot that cannot be recreated without removing it or adding history", async () => { + vi.spyOn(nativeBridge, "getMasterFX").mockResolvedValue([{ + index: 0, + name: "Unidentified plug-in", + type: "vst3", + }]); + const removeSpy = vi.spyOn(nativeBridge, "removeMasterFX").mockResolvedValue(true); + + await expect(useDAWStore.getState().removeMasterFXWithUndo(0)).resolves.toBe(false); + expect(removeSpy).not.toHaveBeenCalled(); + expect(commandManager.canUndo()).toBe(false); + }); +}); diff --git a/frontend/src/__tests__/wheelDeltaAccumulator.test.ts b/frontend/src/__tests__/wheelDeltaAccumulator.test.ts new file mode 100644 index 0000000..106813f --- /dev/null +++ b/frontend/src/__tests__/wheelDeltaAccumulator.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createWheelDeltaAccumulator } from "../utils/wheelDeltaAccumulator"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("wheel delta accumulator", () => { + it("combines tiny high-resolution packets while preserving a 100px notch exactly", () => { + const accumulator = createWheelDeltaAccumulator({ quantum: 1 }); + + expect(accumulator.consume("gain", -0.25)).toBe(0); + expect(accumulator.consume("gain", -0.25)).toBe(0); + expect(accumulator.consume("gain", -0.25)).toBe(0); + expect(accumulator.consume("gain", -0.25)).toBe(-1); + accumulator.reset(); + expect(accumulator.consume("gain", -100)).toBe(-100); + accumulator.dispose(); + }); + + it("drops residue on a direction reversal", () => { + const resets: string[] = []; + const accumulator = createWheelDeltaAccumulator({ + quantum: 1, + onReset: ({ reason }) => resets.push(reason), + }); + + expect(accumulator.consume("gain", 0.75)).toBe(0); + expect(accumulator.consume("gain", -0.5)).toBe(0); + expect(accumulator.getState().remainder).toBe(-0.5); + expect(resets).toEqual(["direction-change"]); + accumulator.dispose(); + }); + + it("does not leak residue when switching targets", () => { + const resets: string[] = []; + const accumulator = createWheelDeltaAccumulator({ + quantum: 1, + onReset: ({ reason, targetKey }) => resets.push(`${reason}:${targetKey}`), + }); + + expect(accumulator.consume("gain", 0.75)).toBe(0); + expect(accumulator.consume("pan", 0.5)).toBe(0); + expect(accumulator.consume("pan", 0.5)).toBe(1); + expect(resets).toEqual(["target-change:gain"]); + accumulator.dispose(); + }); + + it("ends a burst after idle and disposal exactly once", () => { + vi.useFakeTimers(); + const resets: string[] = []; + const accumulator = createWheelDeltaAccumulator({ + quantum: 1, + idleMs: 180, + onReset: ({ reason, targetKey }) => resets.push(`${reason}:${targetKey}`), + }); + + accumulator.consume("gain", 1); + vi.advanceTimersByTime(179); + expect(resets).toEqual([]); + vi.advanceTimersByTime(1); + expect(resets).toEqual(["idle:gain"]); + + accumulator.consume("pan", -0.5); + accumulator.dispose(); + accumulator.dispose(); + expect(resets).toEqual(["idle:gain", "dispose:pan"]); + expect(accumulator.consume("pan", -100)).toBe(0); + vi.runAllTimers(); + expect(resets).toHaveLength(2); + }); +}); diff --git a/frontend/src/__tests__/wheelGestureResolver.test.ts b/frontend/src/__tests__/wheelGestureResolver.test.ts new file mode 100644 index 0000000..f6e196e --- /dev/null +++ b/frontend/src/__tests__/wheelGestureResolver.test.ts @@ -0,0 +1,625 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_WHEEL_NORMALIZATION, + OPENSTUDIO_WHEEL_PROFILE, + inferWheelInputDevice, + normalizeWheelDelta, + normalizeWheelModifiers, + resolveWheelGesture, + type WheelBehaviorProfile, + type WheelEventLike, + type WheelSubtarget, + type WheelSurface, +} from "../utils/wheelGestureResolver"; + +interface ModifierCase { + label: string; + primary: boolean; + secondary: boolean; + alt: boolean; + shift: boolean; + event: WheelEventLike; +} + +const modifierCases: ModifierCase[] = Array.from({ length: 16 }, (_, mask) => { + const primary = Boolean(mask & 1); + const secondary = Boolean(mask & 2); + const alt = Boolean(mask & 4); + const shift = Boolean(mask & 8); + const active = [ + primary ? "Primary" : "", + secondary ? "Secondary" : "", + alt ? "Alt" : "", + shift ? "Shift" : "", + ].filter(Boolean); + return { + label: active.join("+") || "plain", + primary, + secondary, + alt, + shift, + event: { + deltaX: 4, + deltaY: 12, + ctrlKey: primary, + metaKey: secondary, + altKey: alt, + shiftKey: shift, + clientX: 120, + clientY: 240, + }, + }; +}); + +function resolveOpenStudio( + event: WheelEventLike, + surface: WheelSurface, + subtarget?: WheelSubtarget, +) { + return resolveWheelGesture(event, { + surface, + subtarget, + platform: "windows", + hoveredTargetId: "hovered-id", + }); +} + +describe("wheel delta normalization", () => { + it("preserves pixel deltas and sanitizes absent or non-finite values", () => { + expect(normalizeWheelDelta({ deltaX: -2.5, deltaY: 7, deltaMode: 0 })).toEqual({ + x: -2.5, + y: 7, + mode: "pixel", + sourceMode: 0, + isZero: false, + }); + expect(normalizeWheelDelta({ deltaX: Number.NaN, deltaY: Number.POSITIVE_INFINITY })).toEqual({ + x: 0, + y: 0, + mode: "pixel", + sourceMode: 0, + isZero: true, + }); + }); + + it("converts line and page deltas with configurable CSS-pixel units", () => { + expect(normalizeWheelDelta( + { deltaX: 2, deltaY: -3, deltaMode: 1 }, + { lineHeightPx: 20 }, + )).toMatchObject({ x: 40, y: -60, mode: "line", sourceMode: 1 }); + expect(normalizeWheelDelta( + { deltaX: -0.5, deltaY: 1, deltaMode: 2 }, + { pageHeightPx: 640 }, + )).toMatchObject({ x: -320, y: 640, mode: "page", sourceMode: 2 }); + }); + + it("uses safe defaults for invalid options and caps each converted axis", () => { + expect(normalizeWheelDelta( + { deltaX: -1_000, deltaY: 1_000, deltaMode: 1 }, + { lineHeightPx: -1, pageHeightPx: 0, maxAbsDeltaPx: 300 }, + )).toMatchObject({ x: -300, y: 300, mode: "line" }); + expect(DEFAULT_WHEEL_NORMALIZATION).toEqual({ + lineHeightPx: 16, + pageHeightPx: 800, + maxAbsDeltaPx: 2400, + }); + }); + + it("treats unknown delta modes as pixels without losing the diagnostic source mode", () => { + expect(normalizeWheelDelta({ deltaX: 3, deltaY: 5, deltaMode: 99 })).toEqual({ + x: 3, + y: 5, + mode: "pixel", + sourceMode: 99, + isZero: false, + }); + }); + + it("lets the local context override profile normalization", () => { + const profile: WheelBehaviorProfile = { + ...OPENSTUDIO_WHEEL_PROFILE, + id: "normalization-test", + normalization: { lineHeightPx: 10, maxAbsDeltaPx: 1_000 }, + }; + const result = resolveWheelGesture( + { deltaY: 3, deltaMode: 1 }, + { + surface: "timeline", + platform: "windows", + normalization: { lineHeightPx: 25 }, + }, + profile, + ); + expect(result.delta.y).toBe(75); + expect(result.amount).toBe(75); + }); +}); + +describe("wheel modifier normalization", () => { + it("keeps primary, secondary, Alt/Option, and Shift independent on Windows", () => { + expect(normalizeWheelModifiers({ + ctrlKey: true, + metaKey: true, + altKey: true, + shiftKey: true, + }, "windows")).toEqual({ + primary: true, + secondary: true, + alt: true, + shift: true, + raw: { + control: true, + commandOrMeta: true, + altOrOption: true, + shift: true, + }, + }); + }); + + it("maps Command to primary and physical Control to secondary on macOS", () => { + expect(normalizeWheelModifiers({ metaKey: true }, "macos")).toMatchObject({ + primary: true, + secondary: false, + alt: false, + }); + expect(normalizeWheelModifiers({ ctrlKey: true }, "macos")).toMatchObject({ + primary: false, + secondary: true, + alt: false, + }); + expect(normalizeWheelModifiers({ altKey: true }, "macos")).toMatchObject({ + primary: false, + secondary: false, + alt: true, + }); + }); + + it("uses Control as primary and Meta as secondary on other desktop platforms", () => { + expect(normalizeWheelModifiers({ ctrlKey: true }, "other")).toMatchObject({ + primary: true, + secondary: false, + }); + expect(normalizeWheelModifiers({ metaKey: true }, "other")).toMatchObject({ + primary: false, + secondary: true, + }); + }); +}); + +describe("wheel device hints", () => { + it("always trusts an explicit host hint", () => { + expect(inferWheelInputDevice({ deltaY: 120, deltaMode: 1 }, "trackpad")).toEqual({ + device: "trackpad", + basis: "explicit", + }); + expect(inferWheelInputDevice({ deltaY: 0.25 }, "unknown")).toEqual({ + device: "unknown", + basis: "explicit", + }); + }); + + it.each([ + [{ deltaY: 3, deltaMode: 1 }, "mouse", "line-or-page-mode"], + [{ deltaY: 1, deltaMode: 2 }, "mouse", "line-or-page-mode"], + [{ deltaX: 3, deltaY: 4 }, "trackpad", "two-axis-pixel-delta"], + [{ deltaY: 0.5 }, "trackpad", "fractional-pixel-delta"], + [{ deltaY: 120 }, "mouse", "large-integer-pixel-delta"], + [{ deltaY: 12 }, "unknown", "insufficient-signal"], + [{ deltaX: 0, deltaY: 0 }, "unknown", "insufficient-signal"], + ] as const)("classifies %o as %s from %s", (event, device, basis) => { + expect(inferWheelInputDevice(event)).toEqual({ device, basis }); + }); +}); + +describe("OpenStudio Timeline wheel matrix", () => { + it.each(modifierCases)("resolves $label with documented precedence", (entry) => { + const result = resolveOpenStudio(entry.event, "timeline", "content"); + if (entry.primary && entry.shift) { + expect(result).toMatchObject({ + ruleId: "timeline.waveform-amplitude", + operation: "zoom", + target: "waveform-amplitude", + axis: "vertical", + amount: 12, + preventDefault: true, + stopPropagation: true, + }); + expect(result.anchor).toEqual({ + kind: "hovered-track", + clientX: 120, + clientY: 240, + targetId: "hovered-id", + }); + } else if (entry.primary) { + expect(result).toMatchObject({ + ruleId: "timeline.horizontal-zoom", + operation: "zoom", + target: "timeline", + axis: "horizontal", + amount: 12, + preventDefault: true, + stopPropagation: true, + }); + expect(result.anchor.kind).toBe("pointer"); + } else if (entry.alt) { + expect(result).toMatchObject({ + ruleId: "timeline.track-height", + operation: "resize", + target: "track-height", + axis: "vertical", + amount: 12, + }); + } else if (entry.shift) { + expect(result).toMatchObject({ + ruleId: "timeline.horizontal-scroll", + operation: "scroll", + target: "viewport", + axis: "horizontal", + amount: 24, + preventDefault: true, + stopPropagation: false, + }); + } else { + expect(result).toMatchObject({ + ruleId: "timeline.native-scroll", + operation: "native-scroll", + target: "native", + axis: "vertical", + amount: 12, + preventDefault: false, + stopPropagation: false, + }); + } + }); + + it.each(["content", "ruler", "track"] as const)( + "uses pointer-anchored zoom on the %s subtarget", + (subtarget) => { + const result = resolveOpenStudio({ + deltaY: -8, + ctrlKey: true, + clientX: 0, + clientY: 31, + }, "timeline", subtarget); + expect(result.ruleId).toBe("timeline.horizontal-zoom"); + expect(result.anchor).toEqual({ + kind: "pointer", + clientX: 0, + clientY: 31, + targetId: "hovered-id", + }); + }, + ); +}); + +describe("OpenStudio TCP wheel matrix", () => { + it.each(modifierCases)("resolves $label without leaking browser zoom", (entry) => { + const result = resolveOpenStudio(entry.event, "tcp", "track"); + if (entry.alt) { + expect(result).toMatchObject({ + ruleId: "tcp.track-height", + operation: "resize", + target: "track-height", + amount: 12, + preventDefault: true, + stopPropagation: true, + }); + } else if (entry.primary) { + expect(result).toMatchObject({ + ruleId: "tcp.suppress-browser-zoom", + operation: "suppress", + target: "native", + preventDefault: true, + stopPropagation: false, + }); + } else { + expect(result).toMatchObject({ + ruleId: "tcp.native-scroll", + operation: "native-scroll", + axis: "vertical", + preventDefault: false, + }); + } + }); + + it.each(["track", "empty"] as const)("supports the %s subtarget", (subtarget) => { + expect(resolveOpenStudio({ deltaY: 9, altKey: true }, "tcp", subtarget).ruleId) + .toBe("tcp.track-height"); + }); +}); + +describe("OpenStudio Piano Roll wheel matrix", () => { + it.each(modifierCases)("resolves $label in the note grid", (entry) => { + const result = resolveOpenStudio(entry.event, "piano_roll", "grid"); + if (entry.primary) { + expect(result).toMatchObject({ + ruleId: "piano-roll.horizontal-zoom", + operation: "zoom", + target: "timeline", + axis: "horizontal", + amount: 12, + preventDefault: true, + }); + expect(result.anchor.kind).toBe("pointer"); + } else if (entry.shift) { + expect(result).toMatchObject({ + ruleId: "piano-roll.shift-horizontal-scroll", + operation: "scroll", + target: "viewport", + axis: "horizontal", + amount: 16, + }); + } else { + expect(result).toMatchObject({ + ruleId: "piano-roll.dominant-axis-scroll", + operation: "scroll", + target: "viewport", + axis: "vertical", + amount: 12, + }); + } + }); + + it.each(modifierCases)("keeps $label native over the sidebar", (entry) => { + expect(resolveOpenStudio(entry.event, "piano_roll", "sidebar")).toMatchObject({ + ruleId: "piano-roll.sidebar-native-scroll", + operation: "native-scroll", + target: "native", + axis: "vertical", + amount: 12, + preventDefault: false, + stopPropagation: false, + }); + }); + + it("uses native deltaX for horizontal trackpad intent and vertical on a tie", () => { + expect(resolveOpenStudio({ deltaX: -18, deltaY: 4 }, "piano_roll", "grid")) + .toMatchObject({ axis: "horizontal", amount: -18 }); + expect(resolveOpenStudio({ deltaX: 8, deltaY: -8 }, "piano_roll", "grid")) + .toMatchObject({ axis: "vertical", amount: -8 }); + }); + + it.each(["grid", "keyboard", "controller_lane"] as const)( + "applies editor zoom in the %s subtarget", + (subtarget) => { + expect(resolveOpenStudio({ deltaY: 10, ctrlKey: true }, "piano_roll", subtarget).ruleId) + .toBe("piano-roll.horizontal-zoom"); + }, + ); +}); + +describe("OpenStudio Pitch Editor wheel matrix", () => { + it.each(modifierCases)("resolves $label", (entry) => { + const result = resolveOpenStudio(entry.event, "pitch_editor", "grid"); + if (entry.primary) { + expect(result).toMatchObject({ + ruleId: "pitch-editor.horizontal-zoom", + operation: "zoom", + target: "timeline", + axis: "horizontal", + amount: 12, + }); + } else if (entry.shift) { + expect(result).toMatchObject({ + ruleId: "pitch-editor.horizontal-scroll", + operation: "scroll", + target: "viewport", + axis: "horizontal", + amount: 24, + }); + } else { + expect(result).toMatchObject({ + ruleId: "pitch-editor.vertical-pitch-scroll", + operation: "scroll", + target: "viewport", + axis: "vertical", + amount: -12, + }); + } + expect(result.preventDefault).toBe(true); + }); + + it.each(["grid", "keyboard"] as const)("resolves the %s subtarget", (subtarget) => { + expect(resolveOpenStudio({ deltaY: 7 }, "pitch_editor", subtarget).ruleId) + .toBe("pitch-editor.vertical-pitch-scroll"); + }); +}); + +describe("OpenStudio Browser wheel matrix", () => { + it.each(modifierCases)("resolves $label", (entry) => { + const result = resolveOpenStudio(entry.event, "browser", "list"); + if (entry.primary) { + expect(result).toMatchObject({ + ruleId: "browser.suppress-browser-zoom", + operation: "suppress", + target: "native", + axis: "vertical", + preventDefault: true, + }); + } else if (entry.shift) { + expect(result).toMatchObject({ + ruleId: "browser.native-horizontal-scroll", + operation: "native-scroll", + target: "native", + axis: "horizontal", + amount: 16, + preventDefault: false, + }); + } else { + expect(result).toMatchObject({ + ruleId: "browser.native-scroll", + operation: "native-scroll", + target: "native", + axis: "vertical", + amount: 12, + preventDefault: false, + }); + } + }); + + it.each(["list", "tree", "preview"] as const)("supports the %s subtarget", (subtarget) => { + expect(resolveOpenStudio({ deltaX: 15, deltaY: 3 }, "browser", subtarget)) + .toMatchObject({ ruleId: "browser.native-scroll", axis: "horizontal", amount: 15 }); + }); +}); + +describe("OpenStudio Parameter wheel matrix", () => { + it.each(modifierCases)("resolves $label as an owned adjustment", (entry) => { + const result = resolveOpenStudio(entry.event, "parameter", "control"); + expect(result).toMatchObject({ + ruleId: entry.shift ? "parameter.fine-adjust" : "parameter.adjust", + operation: "adjust", + target: "parameter", + axis: "vertical", + amount: 12, + precision: entry.shift ? "fine" : "normal", + preventDefault: true, + stopPropagation: true, + }); + expect(result.anchor).toEqual({ + kind: "hovered-control", + clientX: 120, + clientY: 240, + targetId: "hovered-id", + }); + }); + + it.each(["control", "graph"] as const)("supports the %s subtarget", (subtarget) => { + expect(resolveOpenStudio({ deltaY: -5 }, "parameter", subtarget).ruleId) + .toBe("parameter.adjust"); + }); +}); + +describe("profile-driven wheel behavior", () => { + it("allows a profile to prepend a different behavior without component changes", () => { + const profile: WheelBehaviorProfile = { + id: "custom-reaper-like", + name: "Custom REAPER-like", + rules: [ + { + id: "custom.timeline.plain-zoom", + surface: "timeline", + modifiers: { primary: false, secondary: false, alt: false, shift: false }, + operation: "zoom", + target: "timeline", + axis: "horizontal", + deltaSource: "y", + anchor: "pointer", + preventDefault: true, + stopPropagation: true, + }, + ...OPENSTUDIO_WHEEL_PROFILE.rules, + ], + }; + const result = resolveWheelGesture( + { deltaY: -10, clientX: 42 }, + { surface: "timeline", subtarget: "content", platform: "windows" }, + profile, + ); + expect(result).toMatchObject({ + profileId: "custom-reaper-like", + ruleId: "custom.timeline.plain-zoom", + operation: "zoom", + target: "timeline", + amount: -10, + preventDefault: true, + }); + expect(result.anchor).toMatchObject({ kind: "pointer", clientX: 42 }); + }); + + it("can select rules by inferred or explicit input-device hint", () => { + const profile: WheelBehaviorProfile = { + id: "device-aware", + name: "Device-aware", + rules: [ + { + id: "timeline.trackpad-pan", + surface: "timeline", + devices: ["trackpad"], + operation: "scroll", + target: "viewport", + axis: "dominant", + deltaSource: "dominant", + preventDefault: true, + stopPropagation: false, + }, + { + id: "timeline.mouse-zoom", + surface: "timeline", + devices: ["mouse"], + operation: "zoom", + target: "timeline", + axis: "horizontal", + deltaSource: "y", + preventDefault: true, + stopPropagation: true, + }, + ], + }; + + expect(resolveWheelGesture( + { deltaX: 2, deltaY: 3 }, + { surface: "timeline", platform: "windows" }, + profile, + ).ruleId).toBe("timeline.trackpad-pan"); + expect(resolveWheelGesture( + { deltaY: 1 }, + { surface: "timeline", platform: "windows", deviceHint: "mouse" }, + profile, + ).ruleId).toBe("timeline.mouse-zoom"); + }); + + it("falls back safely to native dominant-axis scrolling when no profile rule matches", () => { + const profile: WheelBehaviorProfile = { + id: "empty", + name: "Empty", + rules: [], + }; + expect(resolveWheelGesture( + { deltaX: -21, deltaY: 4, ctrlKey: true }, + { surface: "browser", platform: "windows" }, + profile, + )).toMatchObject({ + profileId: "empty", + ruleId: null, + matched: false, + operation: "native-scroll", + target: "native", + axis: "horizontal", + amount: -21, + preventDefault: false, + stopPropagation: false, + }); + }); + + it("returns stable zero movement while still resolving ownership", () => { + expect(resolveOpenStudio({ deltaX: 0, deltaY: 0, ctrlKey: true }, "timeline", "content")) + .toMatchObject({ + ruleId: "timeline.horizontal-zoom", + operation: "zoom", + amount: 0, + delta: { isZero: true }, + preventDefault: true, + }); + }); + + it("uses platform-correct primary modifiers while preserving the secondary key", () => { + const macCommand = resolveWheelGesture( + { deltaY: 9, metaKey: true }, + { surface: "timeline", platform: "macos" }, + ); + expect(macCommand).toMatchObject({ + ruleId: "timeline.horizontal-zoom", + modifiers: { primary: true, secondary: false }, + }); + + const macControl = resolveWheelGesture( + { deltaY: 9, ctrlKey: true }, + { surface: "timeline", platform: "macos" }, + ); + expect(macControl).toMatchObject({ + ruleId: "timeline.native-scroll", + modifiers: { primary: false, secondary: true }, + }); + }); +}); diff --git a/frontend/src/__tests__/workspaceStickyHeader.test.ts b/frontend/src/__tests__/workspaceStickyHeader.test.ts index 4adbb56..9e617eb 100644 --- a/frontend/src/__tests__/workspaceStickyHeader.test.ts +++ b/frontend/src/__tests__/workspaceStickyHeader.test.ts @@ -23,4 +23,14 @@ describe("workspace sticky header structure", () => { expect(rulerSource).toContain("TIMELINE_RULER_HEIGHT = 30"); expect(rulerSource).toContain('className="workspace-sticky-ruler"'); }); + + it("routes ruler wheel gestures through the selected DAW profile", () => { + expect(rulerSource).toContain("getMouseBehaviorProfile("); + expect(rulerSource).toContain('surface: "timeline"'); + expect(rulerSource).toContain('subtarget: "ruler"'); + expect(rulerSource).toContain('container.addEventListener("wheel", handleWheel, { passive: false })'); + expect(rulerSource).toContain("getTimelineHorizontalScrollMax("); + expect(rulerSource).toContain("state.recordingClips.length > 0"); + expect(timelineSource).toContain('data-wheel-subtarget="ruler"'); + }); }); diff --git a/frontend/src/assets/nam/amp-cab-card-v2.webp b/frontend/src/assets/nam/amp-cab-card-v2.webp new file mode 100644 index 0000000..4512220 Binary files /dev/null and b/frontend/src/assets/nam/amp-cab-card-v2.webp differ diff --git a/frontend/src/assets/nam/amp-cab-premium-front.webp b/frontend/src/assets/nam/amp-cab-premium-front.webp new file mode 100644 index 0000000..92d11ec Binary files /dev/null and b/frontend/src/assets/nam/amp-cab-premium-front.webp differ diff --git a/frontend/src/assets/nam/amp-cab-premium-v2.webp b/frontend/src/assets/nam/amp-cab-premium-v2.webp new file mode 100644 index 0000000..05744a5 Binary files /dev/null and b/frontend/src/assets/nam/amp-cab-premium-v2.webp differ diff --git a/frontend/src/assets/nam/cab-card.webp b/frontend/src/assets/nam/cab-card.webp new file mode 100644 index 0000000..4f34693 Binary files /dev/null and b/frontend/src/assets/nam/cab-card.webp differ diff --git a/frontend/src/assets/nam/controls/knob-black-atlas.webp b/frontend/src/assets/nam/controls/knob-black-atlas.webp new file mode 100644 index 0000000..d57bd49 Binary files /dev/null and b/frontend/src/assets/nam/controls/knob-black-atlas.webp differ diff --git a/frontend/src/assets/nam/controls/knob-cream-atlas.webp b/frontend/src/assets/nam/controls/knob-cream-atlas.webp new file mode 100644 index 0000000..f28e17d Binary files /dev/null and b/frontend/src/assets/nam/controls/knob-cream-atlas.webp differ diff --git a/frontend/src/assets/nam/controls/knob-metal-atlas.webp b/frontend/src/assets/nam/controls/knob-metal-atlas.webp new file mode 100644 index 0000000..78bad17 Binary files /dev/null and b/frontend/src/assets/nam/controls/knob-metal-atlas.webp differ diff --git a/frontend/src/assets/nam/design/bodies/amp-head-body-v4.webp b/frontend/src/assets/nam/design/bodies/amp-head-body-v4.webp new file mode 100644 index 0000000..92fcc0a Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/amp-head-body-v4.webp differ diff --git a/frontend/src/assets/nam/design/bodies/amp-head-body-v5.webp b/frontend/src/assets/nam/design/bodies/amp-head-body-v5.webp new file mode 100644 index 0000000..7d6a300 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/amp-head-body-v5.webp differ diff --git a/frontend/src/assets/nam/design/bodies/amp-head-body-wide.webp b/frontend/src/assets/nam/design/bodies/amp-head-body-wide.webp new file mode 100644 index 0000000..e356e10 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/amp-head-body-wide.webp differ diff --git a/frontend/src/assets/nam/design/bodies/amp-head-body.webp b/frontend/src/assets/nam/design/bodies/amp-head-body.webp new file mode 100644 index 0000000..11ca81f Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/amp-head-body.webp differ diff --git a/frontend/src/assets/nam/design/bodies/cab-room-integrated-body.webp b/frontend/src/assets/nam/design/bodies/cab-room-integrated-body.webp new file mode 100644 index 0000000..37989c8 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/cab-room-integrated-body.webp differ diff --git a/frontend/src/assets/nam/design/bodies/cabinet-body.webp b/frontend/src/assets/nam/design/bodies/cabinet-body.webp new file mode 100644 index 0000000..05c6e03 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/cabinet-body.webp differ diff --git a/frontend/src/assets/nam/design/bodies/graphic-eq-body-v3.webp b/frontend/src/assets/nam/design/bodies/graphic-eq-body-v3.webp new file mode 100644 index 0000000..dd92ce0 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/graphic-eq-body-v3.webp differ diff --git a/frontend/src/assets/nam/design/bodies/graphic-eq-body-v6.webp b/frontend/src/assets/nam/design/bodies/graphic-eq-body-v6.webp new file mode 100644 index 0000000..77ddb3b Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/graphic-eq-body-v6.webp differ diff --git a/frontend/src/assets/nam/design/bodies/ir-shaper-panel-body.webp b/frontend/src/assets/nam/design/bodies/ir-shaper-panel-body.webp new file mode 100644 index 0000000..4f75b40 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/ir-shaper-panel-body.webp differ diff --git a/frontend/src/assets/nam/design/bodies/mic-panel-body.webp b/frontend/src/assets/nam/design/bodies/mic-panel-body.webp new file mode 100644 index 0000000..322b0c7 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/mic-panel-body.webp differ diff --git a/frontend/src/assets/nam/design/bodies/stompbox-body-blue-wide.webp b/frontend/src/assets/nam/design/bodies/stompbox-body-blue-wide.webp new file mode 100644 index 0000000..cdcda90 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/stompbox-body-blue-wide.webp differ diff --git a/frontend/src/assets/nam/design/bodies/stompbox-body-blue.webp b/frontend/src/assets/nam/design/bodies/stompbox-body-blue.webp new file mode 100644 index 0000000..cd54275 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/stompbox-body-blue.webp differ diff --git a/frontend/src/assets/nam/design/bodies/stompbox-body-dark-wide.webp b/frontend/src/assets/nam/design/bodies/stompbox-body-dark-wide.webp new file mode 100644 index 0000000..573778b Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/stompbox-body-dark-wide.webp differ diff --git a/frontend/src/assets/nam/design/bodies/stompbox-body-dark.webp b/frontend/src/assets/nam/design/bodies/stompbox-body-dark.webp new file mode 100644 index 0000000..c2701d1 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/stompbox-body-dark.webp differ diff --git a/frontend/src/assets/nam/design/bodies/stompbox-body-olive.webp b/frontend/src/assets/nam/design/bodies/stompbox-body-olive.webp new file mode 100644 index 0000000..a265d9b Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/stompbox-body-olive.webp differ diff --git a/frontend/src/assets/nam/design/bodies/stompbox-body-red-wide.webp b/frontend/src/assets/nam/design/bodies/stompbox-body-red-wide.webp new file mode 100644 index 0000000..b33cea1 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/stompbox-body-red-wide.webp differ diff --git a/frontend/src/assets/nam/design/bodies/stompbox-body-red.webp b/frontend/src/assets/nam/design/bodies/stompbox-body-red.webp new file mode 100644 index 0000000..1954b8a Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/stompbox-body-red.webp differ diff --git a/frontend/src/assets/nam/design/bodies/stompbox-body-stone.webp b/frontend/src/assets/nam/design/bodies/stompbox-body-stone.webp new file mode 100644 index 0000000..3d1a49b Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/stompbox-body-stone.webp differ diff --git a/frontend/src/assets/nam/design/bodies/stompbox-body-white-wide.webp b/frontend/src/assets/nam/design/bodies/stompbox-body-white-wide.webp new file mode 100644 index 0000000..07b4f87 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/stompbox-body-white-wide.webp differ diff --git a/frontend/src/assets/nam/design/bodies/wide-pedal-body-copper-deep.webp b/frontend/src/assets/nam/design/bodies/wide-pedal-body-copper-deep.webp new file mode 100644 index 0000000..91a1b09 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/wide-pedal-body-copper-deep.webp differ diff --git a/frontend/src/assets/nam/design/bodies/wide-pedal-body-copper-tall.webp b/frontend/src/assets/nam/design/bodies/wide-pedal-body-copper-tall.webp new file mode 100644 index 0000000..6cc8c83 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/wide-pedal-body-copper-tall.webp differ diff --git a/frontend/src/assets/nam/design/bodies/wide-pedal-body-copper.webp b/frontend/src/assets/nam/design/bodies/wide-pedal-body-copper.webp new file mode 100644 index 0000000..6d518b0 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/wide-pedal-body-copper.webp differ diff --git a/frontend/src/assets/nam/design/bodies/wide-pedal-body-dark-deep.webp b/frontend/src/assets/nam/design/bodies/wide-pedal-body-dark-deep.webp new file mode 100644 index 0000000..97d166b Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/wide-pedal-body-dark-deep.webp differ diff --git a/frontend/src/assets/nam/design/bodies/wide-pedal-body-dark-tall.webp b/frontend/src/assets/nam/design/bodies/wide-pedal-body-dark-tall.webp new file mode 100644 index 0000000..84dfce9 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/wide-pedal-body-dark-tall.webp differ diff --git a/frontend/src/assets/nam/design/bodies/wide-pedal-body-dark.webp b/frontend/src/assets/nam/design/bodies/wide-pedal-body-dark.webp new file mode 100644 index 0000000..87b65e3 Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/wide-pedal-body-dark.webp differ diff --git a/frontend/src/assets/nam/design/bodies/wide-pedal-body-navy-deep.webp b/frontend/src/assets/nam/design/bodies/wide-pedal-body-navy-deep.webp new file mode 100644 index 0000000..b182dfc Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/wide-pedal-body-navy-deep.webp differ diff --git a/frontend/src/assets/nam/design/bodies/wide-pedal-body-navy-tall.webp b/frontend/src/assets/nam/design/bodies/wide-pedal-body-navy-tall.webp new file mode 100644 index 0000000..5e65bed Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/wide-pedal-body-navy-tall.webp differ diff --git a/frontend/src/assets/nam/design/bodies/wide-pedal-body-navy.webp b/frontend/src/assets/nam/design/bodies/wide-pedal-body-navy.webp new file mode 100644 index 0000000..863076c Binary files /dev/null and b/frontend/src/assets/nam/design/bodies/wide-pedal-body-navy.webp differ diff --git a/frontend/src/assets/nam/design/controls/button-black-top.webp b/frontend/src/assets/nam/design/controls/button-black-top.webp new file mode 100644 index 0000000..d382865 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/button-black-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/footswitch-chrome-off-top.webp b/frontend/src/assets/nam/design/controls/footswitch-chrome-off-top.webp new file mode 100644 index 0000000..4cc4ce0 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/footswitch-chrome-off-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/footswitch-chrome-on-top.webp b/frontend/src/assets/nam/design/controls/footswitch-chrome-on-top.webp new file mode 100644 index 0000000..4cc4ce0 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/footswitch-chrome-on-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/footswitch-chrome-pressed-top.webp b/frontend/src/assets/nam/design/controls/footswitch-chrome-pressed-top.webp new file mode 100644 index 0000000..26c69c6 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/footswitch-chrome-pressed-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/knob-black-panel-v4.webp b/frontend/src/assets/nam/design/controls/knob-black-panel-v4.webp new file mode 100644 index 0000000..2e1d692 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/knob-black-panel-v4.webp differ diff --git a/frontend/src/assets/nam/design/controls/knob-black-top.webp b/frontend/src/assets/nam/design/controls/knob-black-top.webp new file mode 100644 index 0000000..78b5ac5 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/knob-black-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/knob-blue-steel-panel-v4.webp b/frontend/src/assets/nam/design/controls/knob-blue-steel-panel-v4.webp new file mode 100644 index 0000000..d3227fd Binary files /dev/null and b/frontend/src/assets/nam/design/controls/knob-blue-steel-panel-v4.webp differ diff --git a/frontend/src/assets/nam/design/controls/knob-blue-steel-top.webp b/frontend/src/assets/nam/design/controls/knob-blue-steel-top.webp new file mode 100644 index 0000000..55bffb3 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/knob-blue-steel-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/knob-cream-top.webp b/frontend/src/assets/nam/design/controls/knob-cream-top.webp new file mode 100644 index 0000000..9f6f053 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/knob-cream-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/knob-metal-top.webp b/frontend/src/assets/nam/design/controls/knob-metal-top.webp new file mode 100644 index 0000000..880135d Binary files /dev/null and b/frontend/src/assets/nam/design/controls/knob-metal-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/led-amber-off-panel-v4.webp b/frontend/src/assets/nam/design/controls/led-amber-off-panel-v4.webp new file mode 100644 index 0000000..17cf6ab Binary files /dev/null and b/frontend/src/assets/nam/design/controls/led-amber-off-panel-v4.webp differ diff --git a/frontend/src/assets/nam/design/controls/led-amber-off-top.webp b/frontend/src/assets/nam/design/controls/led-amber-off-top.webp new file mode 100644 index 0000000..1e0345e Binary files /dev/null and b/frontend/src/assets/nam/design/controls/led-amber-off-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/led-amber-on-panel-v4.webp b/frontend/src/assets/nam/design/controls/led-amber-on-panel-v4.webp new file mode 100644 index 0000000..e5674e5 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/led-amber-on-panel-v4.webp differ diff --git a/frontend/src/assets/nam/design/controls/led-amber-on-top.webp b/frontend/src/assets/nam/design/controls/led-amber-on-top.webp new file mode 100644 index 0000000..59e8963 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/led-amber-on-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/mic-dynamic-57.webp b/frontend/src/assets/nam/design/controls/mic-dynamic-57.webp new file mode 100644 index 0000000..75e0f5f Binary files /dev/null and b/frontend/src/assets/nam/design/controls/mic-dynamic-57.webp differ diff --git a/frontend/src/assets/nam/design/controls/mic-ribbon-121.webp b/frontend/src/assets/nam/design/controls/mic-ribbon-121.webp new file mode 100644 index 0000000..82e242d Binary files /dev/null and b/frontend/src/assets/nam/design/controls/mic-ribbon-121.webp differ diff --git a/frontend/src/assets/nam/design/controls/screw-phillips-top.webp b/frontend/src/assets/nam/design/controls/screw-phillips-top.webp new file mode 100644 index 0000000..601c1ab Binary files /dev/null and b/frontend/src/assets/nam/design/controls/screw-phillips-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/slider-metal-cap-v4.webp b/frontend/src/assets/nam/design/controls/slider-metal-cap-v4.webp new file mode 100644 index 0000000..8e99a5f Binary files /dev/null and b/frontend/src/assets/nam/design/controls/slider-metal-cap-v4.webp differ diff --git a/frontend/src/assets/nam/design/controls/slider-metal-top.webp b/frontend/src/assets/nam/design/controls/slider-metal-top.webp new file mode 100644 index 0000000..a1cb21f Binary files /dev/null and b/frontend/src/assets/nam/design/controls/slider-metal-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/toggle-chrome-panel-v4.webp b/frontend/src/assets/nam/design/controls/toggle-chrome-panel-v4.webp new file mode 100644 index 0000000..1852a63 Binary files /dev/null and b/frontend/src/assets/nam/design/controls/toggle-chrome-panel-v4.webp differ diff --git a/frontend/src/assets/nam/design/controls/toggle-chrome-top.webp b/frontend/src/assets/nam/design/controls/toggle-chrome-top.webp new file mode 100644 index 0000000..2d16e6f Binary files /dev/null and b/frontend/src/assets/nam/design/controls/toggle-chrome-top.webp differ diff --git a/frontend/src/assets/nam/design/controls/washer-chrome-top.webp b/frontend/src/assets/nam/design/controls/washer-chrome-top.webp new file mode 100644 index 0000000..bc8ea3b Binary files /dev/null and b/frontend/src/assets/nam/design/controls/washer-chrome-top.webp differ diff --git a/frontend/src/assets/nam/fx-chorus-library-v1.webp b/frontend/src/assets/nam/fx-chorus-library-v1.webp new file mode 100644 index 0000000..fd05ea9 Binary files /dev/null and b/frontend/src/assets/nam/fx-chorus-library-v1.webp differ diff --git a/frontend/src/assets/nam/pedal-boost-library-v1.webp b/frontend/src/assets/nam/pedal-boost-library-v1.webp new file mode 100644 index 0000000..6bf79a1 Binary files /dev/null and b/frontend/src/assets/nam/pedal-boost-library-v1.webp differ diff --git a/frontend/src/assets/nam/pedal-card-v2.webp b/frontend/src/assets/nam/pedal-card-v2.webp new file mode 100644 index 0000000..df1f676 Binary files /dev/null and b/frontend/src/assets/nam/pedal-card-v2.webp differ diff --git a/frontend/src/assets/nam/pedal-distortion-library-v1.webp b/frontend/src/assets/nam/pedal-distortion-library-v1.webp new file mode 100644 index 0000000..d58bb5a Binary files /dev/null and b/frontend/src/assets/nam/pedal-distortion-library-v1.webp differ diff --git a/frontend/src/assets/nam/pedal-fuzz-library-v1.webp b/frontend/src/assets/nam/pedal-fuzz-library-v1.webp new file mode 100644 index 0000000..a07e8d0 Binary files /dev/null and b/frontend/src/assets/nam/pedal-fuzz-library-v1.webp differ diff --git a/frontend/src/assets/nam/pedal-overdrive-library-v1.webp b/frontend/src/assets/nam/pedal-overdrive-library-v1.webp new file mode 100644 index 0000000..5652f39 Binary files /dev/null and b/frontend/src/assets/nam/pedal-overdrive-library-v1.webp differ diff --git a/frontend/src/assets/nam/pedal-premium-v2.webp b/frontend/src/assets/nam/pedal-premium-v2.webp new file mode 100644 index 0000000..872073a Binary files /dev/null and b/frontend/src/assets/nam/pedal-premium-v2.webp differ diff --git a/frontend/src/assets/nam/rack-studio-backdrop-v2.webp b/frontend/src/assets/nam/rack-studio-backdrop-v2.webp new file mode 100644 index 0000000..18f50de Binary files /dev/null and b/frontend/src/assets/nam/rack-studio-backdrop-v2.webp differ diff --git a/frontend/src/assets/nam/room-ir-library-v1.webp b/frontend/src/assets/nam/room-ir-library-v1.webp new file mode 100644 index 0000000..f96e31e Binary files /dev/null and b/frontend/src/assets/nam/room-ir-library-v1.webp differ diff --git a/frontend/src/components/AITrackHeader.tsx b/frontend/src/components/AITrackHeader.tsx index 7b03258..9dea573 100644 --- a/frontend/src/components/AITrackHeader.tsx +++ b/frontend/src/components/AITrackHeader.tsx @@ -21,13 +21,16 @@ import { import { ColorPicker } from "./ColorPicker"; import { FXChainPanel } from "./FXChainPanel"; import { AIWorkflowModal } from "./AIWorkflowModal"; -import { Button, Input, Knob } from "./ui"; +import { Button, Knob } from "./ui"; import { automationToBackend } from "../store/automationParams"; +import { TrackNameEditor } from "./TrackNameEditor"; import { TCP_HEADER_BUTTON_PAIR_CLASS, TCP_HEADER_PRIMARY_BUTTON_CLASS, TCP_HEADER_TOGGLE_BUTTON_CLASS, } from "./tcpHeaderButtonStyles"; +import { registerScopedActionExecutor } from "../store/actionRegistry"; +import { activateShortcutContext } from "../utils/shortcutContext"; interface AITrackHeaderProps { track: Track; @@ -329,6 +332,8 @@ export const AITrackHeader = React.memo(function AITrackHeader({ setAITrackModel, setAITrackWorkflow, setAITrackParams, + beginAITrackParamsEdit, + commitAITrackParamsEdit, setAITrackGenerationState, addGeneratedAudioClip, trackHeight, @@ -336,6 +341,7 @@ export const AITrackHeader = React.memo(function AITrackHeader({ openAiToolsSetup, autoValues, meterLevel, + selectedTrackId, } = useDAWStore( useShallow((state) => ({ updateTrack: state.updateTrack, @@ -351,6 +357,8 @@ export const AITrackHeader = React.memo(function AITrackHeader({ setAITrackModel: state.setAITrackModel, setAITrackWorkflow: state.setAITrackWorkflow, setAITrackParams: state.setAITrackParams, + beginAITrackParamsEdit: state.beginAITrackParamsEdit, + commitAITrackParamsEdit: state.commitAITrackParamsEdit, setAITrackGenerationState: state.setAITrackGenerationState, addGeneratedAudioClip: state.addGeneratedAudioClip, trackHeight: state.trackHeight, @@ -358,6 +366,7 @@ export const AITrackHeader = React.memo(function AITrackHeader({ openAiToolsSetup: state.openAiToolsSetup, autoValues: state.automatedParamValues[track.id], meterLevel: state.meterLevels[track.id] ?? 0, + selectedTrackId: state.selectedTrackId, })), ); @@ -368,6 +377,32 @@ export const AITrackHeader = React.memo(function AITrackHeader({ const [showColorPicker, setShowColorPicker] = useState(false); const [showParams, setShowParams] = useState(false); const [showFXChain, setShowFXChain] = useState(false); + + useEffect(() => { + if (!isSelected || selectedTrackId !== track.id) return; + const execute = (actionId: string) => { + if (actionId !== "track.openSelectedFxChain") return "unmatched" as const; + const selectedId = useDAWStore.getState().selectedTrackId; + if (selectedId !== track.id) return "claimed_noop" as const; + setShowFXChain(true); + return "handled" as const; + }; + const unregisterTrackHeader = registerScopedActionExecutor( + { kind: "track_control_panel" }, + execute, + ["track.openSelectedFxChain"], + ); + const unregisterMixer = registerScopedActionExecutor( + { kind: "mixer" }, + execute, + ["track.openSelectedFxChain"], + ); + return () => { + unregisterMixer(); + unregisterTrackHeader(); + }; + }, [isSelected, selectedTrackId, track.id]); + const modelId = resolveAiMusicModelId(track.aiMusicModelId); const model = getAiMusicModel(modelId); const workflow = getAIWorkflow(track.aiWorkflow, modelId, "ai-track"); @@ -777,6 +812,10 @@ export const AITrackHeader = React.memo(function AITrackHeader({ <>
activateShortcutContext({ kind: "track_control_panel" })} + onContextMenuCapture={() => activateShortcutContext({ kind: "track_control_panel" })} + onFocusCapture={() => activateShortcutContext({ kind: "track_control_panel" })} + data-shortcut-context="track_control_panel" style={{ height: getEffectiveTrackHeight(track, trackHeight) }} >
@@ -813,12 +852,9 @@ export const AITrackHeader = React.memo(function AITrackHeader({ AI - updateTrack(track.id, { name: event.target.value })} + setAITrackModel(track.id, nextModelId)} onWorkflowChange={(workflowId) => setAITrackWorkflow(track.id, workflowId)} onParamsChange={(params) => setAITrackParams(track.id, params)} + onBeginParamsEdit={() => beginAITrackParamsEdit(track.id)} + onCommitParamsEdit={() => commitAITrackParamsEdit(track.id)} /> ); diff --git a/frontend/src/components/AIWorkflowModal.tsx b/frontend/src/components/AIWorkflowModal.tsx index 43d27e2..ee68964 100644 --- a/frontend/src/components/AIWorkflowModal.tsx +++ b/frontend/src/components/AIWorkflowModal.tsx @@ -39,6 +39,8 @@ interface AIWorkflowModalProps { onModelChange: (modelId: AiMusicModelId) => void; onWorkflowChange: (workflowId: string) => void; onParamsChange: (params: Record) => void; + onBeginParamsEdit?: () => void; + onCommitParamsEdit?: () => void; } const SECTION_ORDER: AIWorkflowSection[] = [ @@ -147,6 +149,8 @@ export function AIWorkflowModal({ onModelChange, onWorkflowChange, onParamsChange, + onBeginParamsEdit, + onCommitParamsEdit, }: AIWorkflowModalProps) { const [advancedOpen, setAdvancedOpen] = useState(false); const [detailsOpen, setDetailsOpen] = useState(false); @@ -260,6 +264,8 @@ export function AIWorkflowModal({ param={param} value={value} onChange={(nextValue) => handleParamChange(param.key, nextValue)} + onBeginEdit={onBeginParamsEdit} + onCommitEdit={onCommitParamsEdit} /> ); } diff --git a/frontend/src/components/AIWorkflowParamField.tsx b/frontend/src/components/AIWorkflowParamField.tsx index 8f1fe68..37695f7 100644 --- a/frontend/src/components/AIWorkflowParamField.tsx +++ b/frontend/src/components/AIWorkflowParamField.tsx @@ -6,6 +6,8 @@ interface NumericWorkflowParamFieldProps { param: AIWorkflowParam; value: unknown; onChange: (value: number) => void; + onBeginEdit?: () => void; + onCommitEdit?: () => void; disabled?: boolean; } @@ -36,6 +38,8 @@ export function NumericWorkflowParamField({ param, value, onChange, + onBeginEdit, + onCommitEdit, disabled = false, }: NumericWorkflowParamFieldProps) { const inputId = useId(); @@ -114,6 +118,8 @@ export function NumericWorkflowParamField({ max={max} step={step} onChange={handleSliderChange} + onBeginEdit={onBeginEdit} + onCommitEdit={onCommitEdit} disabled={disabled} /> {param.description ? ( diff --git a/frontend/src/components/AiToolsSetupModal.tsx b/frontend/src/components/AiToolsSetupModal.tsx index 538e818..b6745fa 100644 --- a/frontend/src/components/AiToolsSetupModal.tsx +++ b/frontend/src/components/AiToolsSetupModal.tsx @@ -23,7 +23,7 @@ const IS_WINDOWS = navigator.platform.startsWith("Win") || navigator.userAgent.i const PYTHON_DOWNLOAD_URL = "https://www.python.org/downloads/"; const STABLE_AUDIO_MODEL_URL = "https://huggingface.co/stabilityai/stable-audio-3-medium"; -const STABLE_AUDIO_INITIAL_PATH = "C:\\Users\\srvds\\Downloads\\stable_audio_3"; +const STABLE_AUDIO_FOLDER_EXAMPLE = `Downloads${IS_WINDOWS ? "\\" : "/"}stable_audio_3`; const STABLE_AUDIO_REQUIRED_FILES = [ "model.safetensors", "model_config.json", @@ -75,17 +75,6 @@ const FEATURE_COPY: Record { if (!installLogPath) return; - await nativeBridge.openExternalURL(toFileUrl(parentPath(installLogPath))); + await nativeBridge.revealLocalPath(installLogPath); }; const handleDownloadPython = async () => { @@ -433,7 +422,7 @@ export default function AiToolsSetupModal() { } if (!folder) { - setStableAudioSetupError(`No folder was selected. Choose the Stable Audio snapshot folder, for example ${STABLE_AUDIO_INITIAL_PATH}.`); + setStableAudioSetupError(`No folder was selected. Choose the Stable Audio snapshot folder, for example ${STABLE_AUDIO_FOLDER_EXAMPLE}.`); return; } @@ -476,10 +465,6 @@ export default function AiToolsSetupModal() { await runStableAudioSetup(folder); }; - const handleUseKnownStableAudioFolder = async () => { - await runStableAudioSetup(STABLE_AUDIO_INITIAL_PATH); - }; - const handleInstallSelected = async () => { if (selectedItem.id === STABLE_AUDIO_3_MODEL_ID) { await handleStableAudioSetup(); @@ -591,14 +576,6 @@ export default function AiToolsSetupModal() { > Proceed with Setup -
diff --git a/frontend/src/components/BuiltInPluginPanel.tsx b/frontend/src/components/BuiltInPluginPanel.tsx index b0ffef9..3629398 100644 --- a/frontend/src/components/BuiltInPluginPanel.tsx +++ b/frontend/src/components/BuiltInPluginPanel.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, useCallback, useEffect, useMemo, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Activity, SlidersHorizontal, X } from "lucide-react"; import { BuiltInParamDescriptor, @@ -8,39 +8,519 @@ import { } from "../services/NativeBridge"; import { ParametricGraph } from "./ParametricGraph"; import type { GraphAxis, GraphNode, GraphNodeConfig } from "./ParametricGraph"; -import { Button } from "./ui"; +import { NAMRackPanel } from "./NAMRackPanel"; +import { Button, ProfiledRangeInput } from "./ui"; +import { registerScopedActionExecutor } from "../store/actionRegistry"; +import { + activateShortcutContext, + getActiveShortcutContext, + registerShortcutSurface, +} from "../utils/shortcutContext"; +import { windowRole } from "../utils/windowEnvironment"; +import { + clampNumber as clamp, + formatParamValue, + isChorusRateParam, + isNAMGraphicEqFilterParam, + normalizeParam as normalize, + normalizeParamValue, + paramValueFromRangeInput, + rangeInputMax, + rangeInputMin, + rangeInputStep, + rangeInputValue, + quantizeParamValue, + stepForParam, +} from "../utils/builtInParamValue"; + +export { formatParamValue, stepForParam }; interface BuiltInPluginPanelProps { address: BuiltInPluginAddress; fallbackName: string; onClose?: () => void; initialSchema?: BuiltInPluginSchema; + shortcutSessionId?: string; } -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); +function getParam(params: BuiltInParamDescriptor[], id: string) { + return params.find((param) => param.id === id); } -export function formatParamValue(param: BuiltInParamDescriptor) { - if (param.type === "toggle") return param.value >= 0.5 ? "On" : "Off"; - if (param.type === "enum") { - return ( - param.enumOptions?.find((option) => Math.round(option.value) === Math.round(param.value)) - ?.label ?? String(Math.round(param.value)) - ); +function makeFallbackParam( + id: string, + label: string, + value: number, + min: number, + max: number, + defaultValue: number, + unit = "", + graphRole = "controls", + type: BuiltInParamDescriptor["type"] = "continuous", + enumOptions?: BuiltInParamDescriptor["enumOptions"], +): BuiltInParamDescriptor { + return { + id, + label, + type, + value, + min, + max, + defaultValue, + unit, + automatable: type !== "meter", + graphRole, + enumOptions, + }; +} + +function isNAMPluginName(name: string) { + return name.toLowerCase().includes("nam"); +} + +export function createNAMBootSchema(address: BuiltInPluginAddress, fallbackName: string): BuiltInPluginSchema { + return { + schemaVersion: 1, + name: fallbackName || "OpenStudio NAM Rack", + category: "NAM", + chain: address.chain, + fxIndex: address.fxIndex ?? -1, + parameters: [ + makeFallbackParam("inputTrimDb", "Input", 0, -24, 24, 0, "dB", "gain"), + { + ...makeFallbackParam("instrumentProfile", "Instrument", 0, 0, 1, 0, "", "global", "enum", [ + { value: 0, label: "Guitar" }, + { value: 1, label: "Bass" }, + ]), + automatable: false, + }, + makeFallbackParam("gateThresholdDb", "Gate", -80, -100, 0, -80, "dB", "dynamics"), + makeFallbackParam("gateReleaseMs", "Gate Rel", 80, 5, 1000, 80, "ms", "dynamics"), + makeFallbackParam("compressorEnabled", "Compressor", 0, 0, 1, 0, "", "dynamics", "toggle"), + makeFallbackParam("compressorAttackMs", "Attack", 21.9, 0.1, 50, 21.9, "ms", "dynamics"), + makeFallbackParam("compressorReleaseMs", "Release", 149.1, 50, 1000, 149.1, "ms", "dynamics"), + makeFallbackParam("compressorToneDb", "Tone", 0, -6, 6, 0, "dB", "dynamics"), + makeFallbackParam("compressorIntensity", "Intensity", 0, 0, 1, 0, "", "dynamics", "toggle"), + makeFallbackParam("compressorSidechainHPF", "HPF", 1, 0, 2, 1, "", "dynamics", "enum", [ + { value: 0, label: "Off" }, + { value: 1, label: "80 Hz" }, + { value: 2, label: "240 Hz" }, + ]), + makeFallbackParam("compressorMix", "Mix", 0.65, 0, 1, 0.65, "", "dynamics"), + makeFallbackParam("compressorVolumeDb", "Level", 0, -18, 18, 0, "dB", "dynamics"), + makeFallbackParam("compressorComp", "Comp", 0.35, 0, 1, 0.35, "", "dynamics"), + makeFallbackParam("preEqEnabled", "PRE EQ", 0, 0, 1, 0, "", "preEq", "toggle"), + makeFallbackParam("preEq120Db", "120 Hz", 0, -12, 12, 0, "dB", "preEq"), + makeFallbackParam("preEq250Db", "250 Hz", 0, -12, 12, 0, "dB", "preEq"), + makeFallbackParam("preEq500Db", "500 Hz", 0, -12, 12, 0, "dB", "preEq"), + makeFallbackParam("preEq1kDb", "1 kHz", 0, -12, 12, 0, "dB", "preEq"), + makeFallbackParam("preEq2k5Db", "2.5 kHz", 0, -12, 12, 0, "dB", "preEq"), + makeFallbackParam("preEq5kDb", "5 kHz", 0, -12, 12, 0, "dB", "preEq"), + makeFallbackParam("preEq8kDb", "8 kHz", 0, -12, 12, 0, "dB", "preEq"), + makeFallbackParam("preEq12kDb", "12 kHz", 0, -12, 12, 0, "dB", "preEq"), + makeFallbackParam("preEqHPFHz", "PRE HPF", 0, 0, 180, 0, "Hz", "preEq"), + makeFallbackParam("preEqLPFHz", "PRE LPF", 24000, 3000, 24000, 24000, "Hz", "preEq"), + makeFallbackParam("precisionDriveEnabled", "Precision Drive", 0, 0, 1, 0, "", "drive", "toggle"), + makeFallbackParam("precisionDriveVolumeDb", "PD Volume", 9, -12, 12, 9, "dB", "drive"), + makeFallbackParam("precisionDriveBright", "PD Bright", 0.55, 0, 1, 0.55, "", "drive"), + makeFallbackParam("precisionDriveAttack", "PD Attack", 0.5, 0, 1, 0.5, "", "drive"), + makeFallbackParam("precisionDriveGate", "PD Gate", 0, 0, 1, 0, "", "drive"), + makeFallbackParam("precisionDriveDrive", "PD Drive", 0.35, 0, 1, 0.35, "", "drive"), + makeFallbackParam("pedalMix", "Pedal Mix", 1, 0, 1, 1, "", "model"), + makeFallbackParam("ampEnabled", "Amp Power", 1, 0, 1, 1, "", "model", "toggle"), + makeFallbackParam("ampGainDb", "Gain", 0, -24, 24, 0, "dB", "model"), + makeFallbackParam("ampBoost", "Tight Boost", 0, 0, 1, 0, "", "model", "toggle"), + makeFallbackParam("ampVoice", "Bright Voice", 0, 0, 1, 0, "", "model", "toggle"), + makeFallbackParam("ampMix", "Amp Mix", 1, 0, 1, 1, "", "model"), + makeFallbackParam("ampOutputDb", "Post Level", 0, -24, 12, 0, "dB", "model"), + makeFallbackParam("bassDb", "Bass", 0, -12, 12, 0, "dB", "tone"), + makeFallbackParam("midDb", "Mid", 0, -12, 12, 0, "dB", "tone"), + makeFallbackParam("trebleDb", "Treble", 0, -12, 12, 0, "dB", "tone"), + makeFallbackParam("presenceDb", "Presence", 0, -12, 12, 0, "dB", "tone"), + makeFallbackParam("eqHPFHz", "HPF", 0, 0, 500, 0, "Hz", "graphicEq"), + makeFallbackParam("eq65Db", "65 Hz", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eq125Db", "125 Hz", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eq250Db", "250 Hz", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eq500Db", "500 Hz", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eq1kDb", "1 kHz", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eq2kDb", "2 kHz", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eq4kDb", "4 kHz", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eq8kDb", "8 kHz", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eq16kDb", "16 kHz", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eqLPFHz", "LPF", 24000, 3000, 24000, 24000, "Hz", "graphicEq"), + makeFallbackParam("eqLevelDb", "Level", 0, -12, 12, 0, "dB", "graphicEq"), + makeFallbackParam("eqEnabled", "EQ Power", 0, 0, 1, 0, "", "graphicEq", "toggle"), + makeFallbackParam("cabEnabled", "Cab/IR", 0, 0, 1, 0, "", "cab", "toggle"), + makeFallbackParam("cabLevelDb", "Cab Level", 0, -24, 12, 0, "dB", "cab"), + makeFallbackParam("cabHPFHz", "Cab HPF", 80, 20, 500, 80, "Hz", "cab"), + makeFallbackParam("cabLPFHz", "Cab LPF", 8500, 1000, 20000, 8500, "Hz", "cab"), + makeFallbackParam("cabPhaseInvert", "Phase", 0, 0, 1, 0, "", "cab", "toggle"), + makeFallbackParam("chorusMix", "Chorus", 0, 0, 1, 0, "", "modulation"), + makeFallbackParam("chorusRateHz", "Chorus Rate", 0.75, 0.01, 8, 0.75, "Hz", "modulation"), + makeFallbackParam("chorusDepth", "Chorus Depth", 0.32, 0, 1, 0.32, "", "modulation"), + makeFallbackParam("delayMix", "Delay", 0.22, 0, 1, 0.22, "", "time"), + makeFallbackParam("delayTimeMs", "Delay Time", 360, 1, 2000, 360, "ms", "time"), + makeFallbackParam("delayFeedback", "Delay Fdbk", 0.22, 0, 0.85, 0.22, "", "time"), + makeFallbackParam("delayMod", "Delay Mod", 0.18, 0, 1, 0.18, "", "time"), + makeFallbackParam("delayDucker", "Ducker", 0.12, 0, 1, 0.12, "", "time"), + makeFallbackParam("delayMode", "Delay Mode", 1, 0, 4, 1, "", "time", "enum", [ + { value: 0, label: "Digital" }, + { value: 1, label: "Tape" }, + { value: 2, label: "Analog" }, + { value: 3, label: "Multi" }, + { value: 4, label: "Dual" }, + ]), + makeFallbackParam("delayPingPong", "Ping Pong", 1, 0, 1, 1, "", "time", "toggle"), + makeFallbackParam("delayTempoSync", "Delay Sync", 0, 0, 1, 0, "", "time", "toggle"), + makeFallbackParam("delayEnabled", "Delay Engage", 0, 0, 1, 0, "", "time", "toggle"), + makeFallbackParam("reverbVoice", "Reverb Voice", 0, 0, 3, 0, "", "space", "enum", [ + { value: 0, label: "Studio" }, + { value: 1, label: "Plate" }, + { value: 2, label: "Hall" }, + { value: 3, label: "Room" }, + ]), + makeFallbackParam("reverbEnabled", "Reverb Engage", 0, 0, 1, 0, "", "space", "toggle"), + makeFallbackParam("reverbMix", "Reverb", 0.28, 0, 1, 0.28, "", "space"), + makeFallbackParam("reverbDecaySec", "Decay", 2.2, 0.2, 12, 2.2, "s", "space"), + makeFallbackParam("reverbPreDelayMs", "Pre Delay", 18, 0, 500, 18, "ms", "space"), + makeFallbackParam("reverbLowCutHz", "Low Cut", 120, 20, 500, 120, "Hz", "space"), + makeFallbackParam("reverbTone", "Verb Tone", 0.62, 0, 1, 0.62, "", "space"), + makeFallbackParam("reverbShimmer", "Shimmer", 0, 0, 1, 0, "", "space"), + makeFallbackParam("reverbPad", "Pad", 0, 0, 1, 0, "", "space", "toggle"), + makeFallbackParam("outputTrimDb", "Output", 0, -24, 24, 0, "dB", "gain"), + ], + modelState: { + pedalModelPath: "", + ampModelPath: "", + cabIRPath: "", + hasPedalModel: false, + hasAmpModel: false, + hasSlimmableNAMModel: false, + hasCabIR: false, + namEffectsDspVersion: 19, + lastLoadError: "", + }, + visualization: { + gainReductionDb: 0, + inputLevelDb: -90, + outputLevelDb: -90, + }, + }; +} + +function isUsableSchema(schema: BuiltInPluginSchema | null | undefined) { + return Boolean(schema && Array.isArray(schema.parameters) && schema.parameters.length > 0); +} + +function valuesClose(param: BuiltInParamDescriptor, value: number) { + if (isChorusRateParam(param) || isNAMGraphicEqFilterParam(param)) { + return Math.abs(normalize(param) - normalizeParamValue(param, value)) <= 1 / 1000; } - const span = Math.abs(param.max - param.min); - const decimals = span <= 2 ? 2 : span <= 50 ? 1 : 0; - return `${param.value.toFixed(decimals)}${param.unit ? ` ${param.unit}` : ""}`; + return Math.abs(param.value - value) <= Math.max(stepForParam(param), 0.0001) * 0.5; } -function normalize(param: BuiltInParamDescriptor) { - if (param.max <= param.min) return 0; - return clamp((param.value - param.min) / (param.max - param.min), 0, 1); +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timeoutId = 0; + const timeout = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId) window.clearTimeout(timeoutId); + }); } -function getParam(params: BuiltInParamDescriptor[], id: string) { - return params.find((param) => param.id === id); +export function createSchemaRequestGate() { + let latestRequestId = 0; + return { + begin() { + latestRequestId += 1; + return latestRequestId; + }, + isLatest(requestId: number) { + return requestId === latestRequestId; + }, + invalidate() { + latestRequestId += 1; + }, + }; +} + +type FailedParamWriteResolution = { + matched: boolean; + rollbackValue?: number; +}; + +/** + * Keeps local parameter feedback responsive without treating that optimistic + * value as native truth. Native schemas are remembered separately so a failed + * write can restore the last value actually observed from the processor. + */ +export function createParamWriteReconciler(initialNativeSchema?: BuiltInPluginSchema | null) { + let optimisticValues: Record = {}; + let confirmedValues: Record = {}; + let preWriteValues: Record = {}; + + const rememberNativeSchema = (nextSchema: BuiltInPluginSchema | null | undefined) => { + if (!isUsableSchema(nextSchema)) return; + const nextConfirmed = { ...confirmedValues }; + for (const param of nextSchema!.parameters) { + if (Number.isFinite(param.value)) nextConfirmed[param.id] = param.value; + } + confirmedValues = nextConfirmed; + }; + + const clearOptimisticValue = (paramId: string) => { + const { [paramId]: _optimistic, ...remainingOptimistic } = optimisticValues; + const { [paramId]: _preWrite, ...remainingPreWrite } = preWriteValues; + optimisticValues = remainingOptimistic; + preWriteValues = remainingPreWrite; + }; + + const overlayOptimisticValues = ( + nextSchema: BuiltInPluginSchema | null | undefined, + confirmMatchingValues: boolean, + ) => { + if (!nextSchema || !isUsableSchema(nextSchema)) return nextSchema ?? null; + if (Object.keys(optimisticValues).length === 0) return nextSchema; + + let schemaChanged = false; + const parameters = nextSchema.parameters.map((entry) => { + const optimisticValue = optimisticValues[entry.id]; + if (typeof optimisticValue !== "number" || !Number.isFinite(optimisticValue)) return entry; + const value = entry.type === "toggle" + ? (optimisticValue >= 0.5 ? 1 : 0) + : clamp(optimisticValue, entry.min, entry.max); + + if (valuesClose(entry, value)) { + // Only a real native response may confirm an optimistic value. A boot + // or cached schema can already contain the local value after setState. + if (confirmMatchingValues) clearOptimisticValue(entry.id); + return entry; + } + + schemaChanged = true; + return { ...entry, value }; + }); + + return schemaChanged ? { ...nextSchema, parameters } : nextSchema; + }; + + rememberNativeSchema(initialNativeSchema); + + return { + beginOptimisticWrite(paramId: string, value: number, previousDisplayedValue?: number) { + if ( + optimisticValues[paramId] === undefined + && typeof previousDisplayedValue === "number" + && Number.isFinite(previousDisplayedValue) + ) { + preWriteValues = { ...preWriteValues, [paramId]: previousDisplayedValue }; + } + optimisticValues = { ...optimisticValues, [paramId]: value }; + }, + + applyToFallbackSchema(nextSchema: BuiltInPluginSchema | null | undefined) { + return overlayOptimisticValues(nextSchema, false); + }, + + acceptNativeSchema(nextSchema: BuiltInPluginSchema | null | undefined) { + rememberNativeSchema(nextSchema); + return overlayOptimisticValues(nextSchema, true); + }, + + resolveSuccessfulWrite(paramId: string, value: number) { + confirmedValues = { ...confirmedValues, [paramId]: value }; + if (!Object.is(optimisticValues[paramId], value)) return false; + clearOptimisticValue(paramId); + return true; + }, + + resolveFailedWrite(paramId: string, value: number): FailedParamWriteResolution { + if (!Object.is(optimisticValues[paramId], value)) return { matched: false }; + const confirmedValue = confirmedValues[paramId]; + const preWriteValue = preWriteValues[paramId]; + clearOptimisticValue(paramId); + const rollbackValue = Number.isFinite(confirmedValue) ? confirmedValue : preWriteValue; + return Number.isFinite(rollbackValue) + ? { matched: true, rollbackValue } + : { matched: true }; + }, + }; +} + +export function shouldReadBackAfterParamWrite( + currentSchema: BuiltInPluginSchema | null | undefined, + paramId: string, +) { + const type = currentSchema?.parameters.find((param) => param.id === paramId)?.type; + return type === "toggle" || type === "enum"; +} + +type FrameCoalescedParamWriterOptions = { + write: (paramId: string, value: number) => Promise; + onSuccess?: (paramId: string, value: number) => void; + onFailure?: (paramId: string, value: number, error?: unknown) => void; + requestFrame?: (callback: FrameRequestCallback) => number; + cancelFrame?: (frameId: number) => void; +}; + +type PendingParamWrite = { + pendingValue?: number; + inFlightValue?: number; + frameId: number | null; +}; + +export function createFrameCoalescedParamWriter({ + write, + onSuccess, + onFailure, + requestFrame = (callback) => window.requestAnimationFrame(callback), + cancelFrame = (frameId) => window.cancelAnimationFrame(frameId), +}: FrameCoalescedParamWriterOptions) { + const writes = new Map(); + const flushWaiters = new Set<{ + failureCount: number; + resolve: (ok: boolean) => void; + }>(); + let acceptingWrites = true; + let terminalFailureCount = 0; + + const entryFor = (paramId: string) => { + const existing = writes.get(paramId); + if (existing) return existing; + const entry: PendingParamWrite = { frameId: null }; + writes.set(paramId, entry); + return entry; + }; + + const hasOutstandingWrites = () => Array.from(writes.values()).some( + (entry) => entry.frameId !== null + || entry.pendingValue !== undefined + || entry.inFlightValue !== undefined, + ); + + const resolveFlushWaitersIfIdle = () => { + if (hasOutstandingWrites()) return; + for (const waiter of flushWaiters) { + waiter.resolve(terminalFailureCount === waiter.failureCount); + } + flushWaiters.clear(); + }; + + const dispatch = async (paramId: string, entry: PendingParamWrite) => { + if (entry.inFlightValue !== undefined || entry.pendingValue === undefined) return; + const value = entry.pendingValue; + entry.pendingValue = undefined; + entry.inFlightValue = value; + + let ok = false; + let writeError: unknown; + try { + ok = await write(paramId, value); + } catch (error) { + writeError = error; + } + + entry.inFlightValue = undefined; + if (ok) { + // Repeated pointer events may have queued the same quantized value while + // this write was in flight. The completed write already delivered it. + if (entry.pendingValue !== undefined && Object.is(entry.pendingValue, value)) { + entry.pendingValue = undefined; + } + onSuccess?.(paramId, value); + } else if (entry.pendingValue === undefined && acceptingWrites) { + // Recover only when the failed value is still the trailing value. A newer + // pending value should get its chance to reach the processor first. + terminalFailureCount += 1; + onFailure?.(paramId, value, writeError); + } + + if (entry.pendingValue !== undefined) { + if (acceptingWrites) schedule(paramId, entry); + else void dispatch(paramId, entry); + } + resolveFlushWaitersIfIdle(); + }; + + const schedule = (paramId: string, entry: PendingParamWrite) => { + if ( + !acceptingWrites + || entry.frameId !== null + || entry.inFlightValue !== undefined + || entry.pendingValue === undefined + ) { + return; + } + if (flushWaiters.size > 0) { + void dispatch(paramId, entry); + return; + } + entry.frameId = requestFrame(() => { + entry.frameId = null; + void dispatch(paramId, entry); + }); + }; + + const queueValue = (paramId: string, value: number, dispatchNow: boolean) => { + if (!acceptingWrites) return; + const entry = entryFor(paramId); + if (entry.pendingValue !== undefined && Object.is(entry.pendingValue, value)) return; + // Do not suppress a value merely because this editor wrote it previously. + // Preset recall, A/B compare, project restore, and automation can all change + // the native parameter without passing through this writer. Treating the + // last successful UI write as authoritative made the first toggle after a + // preset recall update only the optimistic UI while leaving the DSP in its + // recalled state. + entry.pendingValue = value; + if (dispatchNow && entry.frameId !== null) { + cancelFrame(entry.frameId); + entry.frameId = null; + } + if (dispatchNow) void dispatch(paramId, entry); + else schedule(paramId, entry); + }; + + return { + enqueue(paramId: string, value: number) { + queueValue(paramId, value, false); + }, + writeImmediately(paramId: string, value: number) { + queueValue(paramId, value, true); + }, + flush(): Promise { + if (!acceptingWrites) return Promise.resolve(false); + const failureCount = terminalFailureCount; + return new Promise((resolve) => { + flushWaiters.add({ failureCount, resolve }); + for (const [paramId, entry] of writes) { + if (entry.frameId !== null) { + cancelFrame(entry.frameId); + entry.frameId = null; + } + if (entry.pendingValue !== undefined && entry.inFlightValue === undefined) { + void dispatch(paramId, entry); + } + } + resolveFlushWaitersIfIdle(); + }); + }, + dispose(flushPending = true) { + acceptingWrites = false; + for (const [paramId, entry] of writes) { + if (entry.frameId !== null) { + cancelFrame(entry.frameId); + entry.frameId = null; + } + if (flushPending && entry.pendingValue !== undefined && entry.inFlightValue === undefined) { + void dispatch(paramId, entry); + } else if (!flushPending) { + entry.pendingValue = undefined; + } + } + resolveFlushWaitersIfIdle(); + }, + }; } type BuiltInPluginKind = @@ -51,8 +531,10 @@ type BuiltInPluginKind = | "modulation" | "saturation" | "pitch" + | "nam" | "synth" | "piano" + | "guitar" | "drums" | "generic"; @@ -65,6 +547,8 @@ export function getPluginKind(schema: BuiltInPluginSchema | null): BuiltInPlugin if (label.includes("chorus") || label.includes("flanger") || label.includes("phaser") || label.includes("modulation")) return "modulation"; if (label.includes("saturat")) return "saturation"; if (label.includes("pitch")) return "pitch"; + if (label.includes("nam")) return "nam"; + if (label.includes("guitar")) return "guitar"; if (label.includes("piano")) return "piano"; if (label.includes("drum")) return "drums"; if (label.includes("synth") || label.includes("sampler")) return "synth"; @@ -79,7 +563,9 @@ export function primaryParamIdsForKind(kind: BuiltInPluginKind, schema: BuiltInP if (kind === "modulation") return ["mode", "rate", "depth", "mix", "characterMode"]; if (kind === "saturation") return ["satType", "drive", "mix", "outputGain", "oversampleMode"]; if (kind === "pitch") return ["key", "scale", "retuneSpeed", "correctionStrength", "mix"]; + if (kind === "nam") return ["inputTrimDb", "gateThresholdDb", "cabEnabled", "chorusMix", "delayMix", "reverbMix", "outputTrimDb"]; if (kind === "piano") return ["model", "tone", "body", "resonance", "outputGain"]; + if (kind === "guitar") return ["model", "tone", "body", "bendRangeSemitones", "outputGain"]; if (kind === "drums") return ["kit", "mapPreset", "punch", "ambience", "outputGain"]; if (kind === "synth") return ["brightness", "detuneCents", "subLevel", "noiseLevel", "outputGain"]; if (kind === "dynamics" && name.includes("limiter")) return ["threshold", "ceiling", "lookaheadMs", "releaseMs"]; @@ -105,6 +591,7 @@ export function groupLabel(group: string) { instrument: "Instrument", midi: "MIDI", mix: "Mix", + model: "Models", modulation: "Modulation", oscillator: "Oscillators", output: "Output", @@ -130,8 +617,10 @@ export function groupSortWeight(kind: BuiltInPluginKind, group: string) { modulation: ["modulation", "feedback", "character", "tone", "width", "mix"], saturation: ["drive", "character", "tone", "quality", "mix", "output"], pitch: ["scale", "correction", "detection", "formant", "midi", "mix"], + nam: ["model", "gain", "dynamics", "cab", "tone", "modulation", "time", "space"], synth: ["oscillator", "tone", "envelope", "output"], piano: ["character", "tone", "body", "width", "envelope", "output"], + guitar: ["character", "tone", "body", "midi", "space", "envelope", "output"], drums: ["drums", "character", "space", "width", "output"], generic: ["controls", "output"], }; @@ -140,14 +629,6 @@ export function groupSortWeight(kind: BuiltInPluginKind, group: string) { return index === -1 ? 100 : index; } -export function stepForParam(param: BuiltInParamDescriptor) { - const span = Math.abs(param.max - param.min); - if (param.type === "toggle" || param.type === "enum") return 1; - if (param.unit === "Hz" && param.max > 1000) return 1; - if (param.unit === "ms" || param.unit === "s" || param.unit === "dB" || param.unit === "st" || param.unit === "ct") return Math.max(span / 500, 0.01); - return Math.max(span / 500, 0.001); -} - export function BuiltInParamControl({ param, onChange, @@ -203,13 +684,15 @@ export function BuiltInParamControl({ {param.label} {formatParamValue(param)} - onChange(param, Number(event.currentTarget.value))} + onChange( + param, + paramValueFromRangeInput(param, value), + )} /> @@ -609,48 +1092,193 @@ export function BuiltInPluginPanel({ fallbackName, onClose, initialSchema, + shortcutSessionId, }: BuiltInPluginPanelProps) { - const [schema, setSchema] = useState(initialSchema ?? null); + const bootSchema = useMemo( + () => (isNAMPluginName(fallbackName) ? createNAMBootSchema(address, fallbackName) : null), + [address, fallbackName], + ); + const [schema, setSchema] = useState(initialSchema ?? bootSchema); const [loading, setLoading] = useState(false); + const paramWriteReconcilerRef = useRef | null>(null); + if (!paramWriteReconcilerRef.current) { + paramWriteReconcilerRef.current = createParamWriteReconciler(initialSchema); + } + const schemaRequestGateRef = useRef(createSchemaRequestGate()); + const schemaRef = useRef(schema); + const closeRef = useRef(onClose); + schemaRef.current = schema; + closeRef.current = onClose; + const pluginShortcutSessionId = shortcutSessionId + ?? `builtin:${address.chain}:${address.trackId ?? "master"}:${address.fxIndex ?? -1}`; + + useEffect(() => { + const context = { kind: "plugin", sessionId: pluginShortcutSessionId } as const; + const fallback = getActiveShortcutContext(); + const unregisterSurface = registerShortcutSurface( + context, + () => "unmatched", + fallback, + ); + const unregisterActions = registerScopedActionExecutor( + context, + (actionId) => { + if (actionId !== "fx.close") return "unmatched"; + if (!closeRef.current) return "claimed_noop"; + closeRef.current(); + return "handled"; + }, + ["fx.close"], + ); + if (windowRole !== "main") activateShortcutContext(context); + return () => { + unregisterActions(); + unregisterSurface(); + }; + }, [pluginShortcutSessionId]); + + const applyOptimisticParamValues = useCallback((nextSchema: BuiltInPluginSchema | null | undefined) => { + return paramWriteReconcilerRef.current!.applyToFallbackSchema(nextSchema); + }, []); const loadSchema = useCallback(async (showLoading = true) => { + const requestId = schemaRequestGateRef.current.begin(); if (showLoading) setLoading(true); try { - const nextSchema = await nativeBridge.getBuiltInPluginSchema(address); - setSchema(nextSchema); + const nextSchema = await withTimeout(nativeBridge.getBuiltInPluginSchema(address), 2500, "Built-in plugin schema"); + if (!schemaRequestGateRef.current.isLatest(requestId)) return null; + + const acceptedSchema = isUsableSchema(nextSchema) + ? paramWriteReconcilerRef.current!.acceptNativeSchema(nextSchema) + : bootSchema + ? (isUsableSchema(schemaRef.current) + ? applyOptimisticParamValues(schemaRef.current) + : applyOptimisticParamValues(bootSchema)) + : applyOptimisticParamValues(nextSchema); + schemaRef.current = acceptedSchema; + setSchema(acceptedSchema); + return acceptedSchema; } catch (error) { + if (!schemaRequestGateRef.current.isLatest(requestId)) return null; console.error("[BuiltInPluginPanel] Failed to load schema:", error); - setSchema({ - schemaVersion: 1, - name: fallbackName, - category: "Built-in", - chain: address.chain, - fxIndex: address.fxIndex ?? -1, - parameters: [], - }); + const current = schemaRef.current; + const acceptedSchema = isUsableSchema(current) + ? applyOptimisticParamValues(current) + : applyOptimisticParamValues(bootSchema ?? current ?? { + schemaVersion: 1, + name: fallbackName, + category: "Built-in", + chain: address.chain, + fxIndex: address.fxIndex ?? -1, + parameters: [], + }); + schemaRef.current = acceptedSchema; + setSchema(acceptedSchema); + return acceptedSchema; } finally { - if (showLoading) setLoading(false); + if (showLoading && schemaRequestGateRef.current.isLatest(requestId)) setLoading(false); + } + }, [address, applyOptimisticParamValues, bootSchema, fallbackName]); + + const loadSchemaRef = useRef(loadSchema); + loadSchemaRef.current = loadSchema; + + const applyLocalParamValue = useCallback((paramId: string, value: number) => { + const current = schemaRef.current; + if (!current) return; + const currentParam = current.parameters.find((param) => param.id === paramId); + if (!currentParam || Object.is(currentParam.value, value)) return; + const nextSchema = { + ...current, + parameters: current.parameters.map((param) => ( + param.id === paramId ? { ...param, value } : param + )), + }; + schemaRef.current = nextSchema; + setSchema(nextSchema); + }, []); + + const recoverFailedParamWrite = useCallback((paramId: string, value: number, error?: unknown) => { + if (error !== undefined) { + console.error("[BuiltInPluginPanel] Failed to set built-in parameter:", error); } - }, [address, fallbackName]); + const resolution = paramWriteReconcilerRef.current!.resolveFailedWrite(paramId, value); + if (!resolution.matched) return; + if (resolution.rollbackValue !== undefined) { + applyLocalParamValue(paramId, resolution.rollbackValue); + } + void loadSchemaRef.current(false); + }, [applyLocalParamValue]); + + const confirmSuccessfulParamWrite = useCallback((paramId: string, value: number) => { + paramWriteReconcilerRef.current!.resolveSuccessfulWrite(paramId, value); + // NAM deliberately has no recurring full-schema poll. Discrete controls get + // one readback after their acknowledged write so the UI follows automation, + // preset recall, or a processor that resolved the requested value differently. + if (shouldReadBackAfterParamWrite(schemaRef.current, paramId)) { + void loadSchemaRef.current(false); + } + }, []); + + const writeAddress = useMemo( + () => ({ + chain: address.chain, + trackId: address.trackId, + fxIndex: address.fxIndex, + }), + [address.chain, address.fxIndex, address.trackId], + ); + + const paramWriter = useMemo( + () => createFrameCoalescedParamWriter({ + write: (paramId, value) => nativeBridge.setBuiltInPluginParam(writeAddress, paramId, value), + onSuccess: confirmSuccessfulParamWrite, + onFailure: recoverFailedParamWrite, + }), + [confirmSuccessfulParamWrite, recoverFailedParamWrite, writeAddress], + ); useEffect(() => { if (initialSchema) { - setSchema(initialSchema); + schemaRequestGateRef.current.invalidate(); + const acceptedSchema = paramWriteReconcilerRef.current!.acceptNativeSchema(initialSchema); + schemaRef.current = acceptedSchema; + setSchema(acceptedSchema); + setLoading(false); return; } + if (bootSchema) setSchema((current) => (isUsableSchema(current) ? current : bootSchema)); void loadSchema(); - }, [initialSchema, loadSchema]); + }, [bootSchema, initialSchema, loadSchema]); useEffect(() => { const pluginKind = `${schema?.category ?? ""} ${schema?.name ?? ""}`.toLowerCase(); - const needsLiveSchema = pluginKind.includes("eq") || pluginKind.includes("pitch") || pluginKind.includes("dynamics") || pluginKind.includes("compressor") || pluginKind.includes("gate") || pluginKind.includes("limiter"); + // NAM has a dedicated low-cost diagnostics endpoint. Keep periodic meter + // refreshes separate from rebuilding and transferring the complete schema. + const needsLiveSchema = pluginKind.includes("eq") + || pluginKind.includes("pitch") + || pluginKind.includes("dynamics") + || pluginKind.includes("compressor") + || pluginKind.includes("gate") + || pluginKind.includes("limiter"); if (!needsLiveSchema) return; + let refreshInFlight = false; const intervalId = window.setInterval(() => { - void loadSchema(false); + if (refreshInFlight) return; + refreshInFlight = true; + void loadSchema(false).finally(() => { + refreshInFlight = false; + }); }, 500); return () => window.clearInterval(intervalId); }, [loadSchema, schema?.category, schema?.name]); + useEffect(() => () => { + schemaRequestGateRef.current.invalidate(); + }, []); + + useEffect(() => () => paramWriter.dispose(true), [paramWriter]); + const pluginKind = useMemo(() => getPluginKind(schema), [schema]); const primaryParamIds = useMemo( @@ -677,86 +1305,124 @@ export function BuiltInPluginPanel({ .sort(([groupA], [groupB]) => groupSortWeight(pluginKind, groupA) - groupSortWeight(pluginKind, groupB)); }, [pluginKind, primaryParams, schema]); - const handleParamChange = async (param: BuiltInParamDescriptor, rawValue: number) => { - const value = param.type === "toggle" ? (rawValue >= 0.5 ? 1 : 0) : clamp(rawValue, param.min, param.max); - setSchema((current) => - current - ? { - ...current, - parameters: current.parameters.map((entry) => - entry.id === param.id ? { ...entry, value } : entry, - ), - } - : current, + const handleParamChange = (param: BuiltInParamDescriptor, rawValue: number) => { + const value = param.type === "toggle" + ? (rawValue >= 0.5 ? 1 : 0) + : quantizeParamValue(param, clamp(rawValue, param.min, param.max)); + const previousDisplayedValue = schemaRef.current?.parameters.find( + (entry) => entry.id === param.id, + )?.value ?? param.value; + paramWriteReconcilerRef.current!.beginOptimisticWrite( + param.id, + value, + previousDisplayedValue, ); - await nativeBridge.setBuiltInPluginParam(address, param.id, value); + applyLocalParamValue(param.id, value); + + if (param.type === "continuous") { + paramWriter.enqueue(param.id, value); + return; + } + + paramWriter.writeImmediately(param.id, value); }; const title = schema?.name || fallbackName; + const displayTitle = pluginKind === "nam" ? "NAM Rack" : title; return ( -
event.stopPropagation()}> +
event.stopPropagation()} + onPointerDownCapture={() => activateShortcutContext({ kind: "plugin", sessionId: pluginShortcutSessionId })} + onFocusCapture={() => activateShortcutContext({ kind: "plugin", sessionId: pluginShortcutSessionId })} + >
- {title} + {displayTitle}
- {onClose && ( - + {pluginKind === "nam" ? ( +
+ {onClose && ( + + )} +
+ ) : ( + onClose && ( + + ) )}
- {schema && schema.parameters.length > 0 && ( - Loading
+ ) : pluginKind === "nam" ? ( + { void handleParamChange(param, value); }} + onFlushPendingParamWrites={() => paramWriter.flush()} + onRefreshRack={() => loadSchema(false)} /> - )} - - {loading ? ( -
Loading
) : !schema || schema.parameters.length === 0 ? (
No editable parameters
) : ( -
- {primaryParams.length > 0 && ( -
- {primaryParams.map((param) => ( - { - void handleParamChange(nextParam, value); - }} - /> - ))} -
+ <> + {schema.parameters.length > 0 && ( + { + void handleParamChange(param, value); + }} + /> )} - {groupedParams.map(([group, params]) => ( -
-
- - {groupLabel(group)} -
-
- {params.map((param) => ( +
+ {primaryParams.length > 0 && ( +
+ {primaryParams.map((param) => ( { void handleParamChange(nextParam, value); }} /> ))}
-
- ))} -
+ )} + {groupedParams.map(([group, params]) => ( +
+
+ + {groupLabel(group)} +
+
+ {params.map((param) => ( + { + void handleParamChange(nextParam, value); + }} + /> + ))} +
+
+ ))} +
+ )} ); diff --git a/frontend/src/components/ChannelStrip.tsx b/frontend/src/components/ChannelStrip.tsx index 5eccc6e..d59a8b0 100644 --- a/frontend/src/components/ChannelStrip.tsx +++ b/frontend/src/components/ChannelStrip.tsx @@ -1,8 +1,9 @@ -import React, { useState, useCallback, useMemo } from "react"; +import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; import classNames from "classnames"; import { ChevronDown, Power } from "lucide-react"; import { PeakMeter } from "./PeakMeter"; import { MasterPeakMeterCluster } from "./MasterPeakMeterCluster"; +import { resolveTrackMeterPresentation } from "../utils/trackMeterPresentation"; import { useDAWStore, Track, @@ -15,6 +16,11 @@ import { Button, Slider } from "./ui"; import { useContextMenu, MenuItem } from "./ContextMenu"; import { automationToBackend } from "../store/automationParams"; import { nativeBridge } from "../services/NativeBridge"; +import { registerScopedActionExecutor } from "../store/actionRegistry"; +import { + getParameterWheelValue, + resolveProfiledParameterWheel, +} from "../utils/parameterWheel"; import { CHANNEL_STRIP_DB_LABEL_FONT_CLASS, CHANNEL_STRIP_DB_LABEL_WIDTH_CLASS, @@ -60,8 +66,15 @@ export const ChannelStrip = React.memo(function ChannelStrip({ // This component re-renders at 10Hz for metering; keeping it isolated from // the tracks array means Timeline/App never see those re-renders. const meterLevel = useDAWStore((s) => s.meterLevels[track.id] ?? 0); + const midiInputLevel = useDAWStore((s) => s.midiInputLevels[track.id] ?? 0); const clipping = useDAWStore((s) => s.clippingStates[track.id] ?? false); const autoValues = useDAWStore((s) => s.automatedParamValues[track.id]); + const meterPresentation = resolveTrackMeterPresentation( + meterLevel, + midiInputLevel, + track.armed, + track.type, + ); const { toggleTrackMute, @@ -75,10 +88,15 @@ export const ChannelStrip = React.memo(function ChannelStrip({ setMasterPan, beginTrackVolumeEdit, commitTrackVolumeEdit, + beginTrackVolumeBatchEdit, + adjustTrackVolumeBatch, + commitTrackVolumeBatchEdit, beginTrackPanEdit, commitTrackPanEdit, - beginAutomationParamTouch, - endAutomationParamTouch, + beginMasterVolumeEdit, + commitMasterVolumeEdit, + beginMasterPanEdit, + commitMasterPanEdit, selectedTrackIds, trackGroups, addTrackGroup, @@ -111,10 +129,15 @@ export const ChannelStrip = React.memo(function ChannelStrip({ setMasterPan: s.setMasterPan, beginTrackVolumeEdit: s.beginTrackVolumeEdit, commitTrackVolumeEdit: s.commitTrackVolumeEdit, + beginTrackVolumeBatchEdit: s.beginTrackVolumeBatchEdit, + adjustTrackVolumeBatch: s.adjustTrackVolumeBatch, + commitTrackVolumeBatchEdit: s.commitTrackVolumeBatchEdit, beginTrackPanEdit: s.beginTrackPanEdit, commitTrackPanEdit: s.commitTrackPanEdit, - beginAutomationParamTouch: s.beginAutomationParamTouch, - endAutomationParamTouch: s.endAutomationParamTouch, + beginMasterVolumeEdit: s.beginMasterVolumeEdit, + commitMasterVolumeEdit: s.commitMasterVolumeEdit, + beginMasterPanEdit: s.beginMasterPanEdit, + commitMasterPanEdit: s.commitMasterPanEdit, selectedTrackIds: s.selectedTrackIds, trackGroups: s.trackGroups, addTrackGroup: s.addTrackGroup, @@ -146,6 +169,25 @@ export const ChannelStrip = React.memo(function ChannelStrip({ }, [track.id]); const [showFXChain, setShowFXChain] = useState(false); + useEffect(() => { + if (!isMaster && !isSelected) return undefined; + return registerScopedActionExecutor( + { kind: "mixer" }, + (actionId) => { + if (isMaster && actionId === "mixer.openMasterFxChain") { + setShowFXChain(true); + return "handled"; + } + if (!isMaster && actionId === "track.openSelectedFxChain") { + if (useDAWStore.getState().selectedTrackId !== track.id) return "claimed_noop"; + setShowFXChain(true); + return "handled"; + } + return "unmatched"; + }, + isMaster ? ["mixer.openMasterFxChain"] : ["track.openSelectedFxChain"], + ); + }, [isMaster, isSelected, track.id]); const hasBypassableFx = track.inputFxCount + track.trackFxCount > 0; const hasFx = hasBypassableFx || Boolean(track.instrumentPlugin) || (track.type === "instrument" && !track.instrumentPlugin); @@ -301,42 +343,98 @@ export const ChannelStrip = React.memo(function ChannelStrip({ } }; - // Undo/redo: capture starting value on pointer down, commit on pointer up - const handleVolumePointerDown = useCallback(() => { + const beginVolumeEdit = useCallback(() => { if (isMaster) { - beginAutomationParamTouch("master", "volume"); - const endTouchOnUp = () => { - document.removeEventListener("pointerup", endTouchOnUp); - endAutomationParamTouch("master", "volume"); - }; - document.addEventListener("pointerup", endTouchOnUp); + beginMasterVolumeEdit(); return; } beginTrackVolumeEdit(track.id); - const commitOnUp = () => { - document.removeEventListener("pointerup", commitOnUp); - commitTrackVolumeEdit(track.id); - }; - document.addEventListener("pointerup", commitOnUp); - }, [isMaster, track.id, beginTrackVolumeEdit, commitTrackVolumeEdit, beginAutomationParamTouch, endAutomationParamTouch]); - - const handlePanPointerDown = useCallback(() => { + }, [beginMasterVolumeEdit, beginTrackVolumeEdit, isMaster, track.id]); + + const commitVolumeEdit = useCallback(() => { if (isMaster) { - beginAutomationParamTouch("master", "pan"); - const endTouchOnUp = () => { - document.removeEventListener("pointerup", endTouchOnUp); - endAutomationParamTouch("master", "pan"); - }; - document.addEventListener("pointerup", endTouchOnUp); + commitMasterVolumeEdit(); + return; + } + commitTrackVolumeEdit(track.id); + }, [commitMasterVolumeEdit, commitTrackVolumeEdit, isMaster, track.id]); + + const beginPanEdit = useCallback(() => { + if (isMaster) { + beginMasterPanEdit(); return; } beginTrackPanEdit(track.id); - const commitOnUp = () => { - document.removeEventListener("pointerup", commitOnUp); - commitTrackPanEdit(track.id); - }; - document.addEventListener("pointerup", commitOnUp); - }, [isMaster, track.id, beginTrackPanEdit, commitTrackPanEdit, beginAutomationParamTouch, endAutomationParamTouch]); + }, [beginMasterPanEdit, beginTrackPanEdit, isMaster, track.id]); + + const commitPanEdit = useCallback(() => { + if (isMaster) { + commitMasterPanEdit(); + return; + } + commitTrackPanEdit(track.id); + }, [commitMasterPanEdit, commitTrackPanEdit, isMaster, track.id]); + + const groupedVolumeWheelTimerRef = useRef(null); + const groupedVolumeWheelModeRef = useRef<"all" | "selected" | null>(null); + const commitGroupedVolumeWheel = useCallback(() => { + if (groupedVolumeWheelTimerRef.current !== null) { + window.clearTimeout(groupedVolumeWheelTimerRef.current); + groupedVolumeWheelTimerRef.current = null; + } + if (groupedVolumeWheelModeRef.current === null) return; + groupedVolumeWheelModeRef.current = null; + commitTrackVolumeBatchEdit(); + }, [commitTrackVolumeBatchEdit]); + + useEffect(() => () => commitGroupedVolumeWheel(), [commitGroupedVolumeWheel]); + + const handleGroupedVolumeWheel = useCallback((event: React.WheelEvent) => { + if (isMaster) return; + const gesture = resolveProfiledParameterWheel(event.nativeEvent, "console_fader"); + const mode = gesture.ruleId === "cakewalk-sonar.console-all-faders" + ? "all" + : gesture.ruleId === "cakewalk-sonar.console-selected-faders" + ? "selected" + : null; + if (!mode) return; + + if (gesture.preventDefault) event.preventDefault(); + if (gesture.stopPropagation) event.stopPropagation(); + + if (groupedVolumeWheelModeRef.current !== null && groupedVolumeWheelModeRef.current !== mode) { + commitGroupedVolumeWheel(); + } + if (groupedVolumeWheelModeRef.current === null) { + const state = useDAWStore.getState(); + const targetIds = mode === "all" + ? state.tracks.map((candidate) => candidate.id) + : state.selectedTrackIds; + if (!beginTrackVolumeBatchEdit(targetIds)) return; + groupedVolumeWheelModeRef.current = mode; + } + + // Cakewalk's grouped rules intentionally remain `suppress` in the generic + // Slider resolver. Convert only their signed wheel amount here, so the + // hovered Slider cannot also apply a second, single-fader adjustment. + const deltaDB = getParameterWheelValue( + { ...gesture, operation: "adjust" }, + { min: -60, max: 12, value: 0, step: 0.1 }, + ); + adjustTrackVolumeBatch(deltaDB); + if (groupedVolumeWheelTimerRef.current !== null) { + window.clearTimeout(groupedVolumeWheelTimerRef.current); + } + groupedVolumeWheelTimerRef.current = window.setTimeout( + commitGroupedVolumeWheel, + 180, + ); + }, [ + adjustTrackVolumeBatch, + beginTrackVolumeBatchEdit, + commitGroupedVolumeWheel, + isMaster, + ]); const formatVolume = (db: number) => { if (db <= -60) return "-∞"; @@ -634,10 +732,7 @@ export const ChannelStrip = React.memo(function ChannelStrip({
{/* Pan Section */} -
+
{/* Vertical Fader */} -
+
{ if (!isOpen || !trackId || trackId === prevTrackIdRef.current) return; prevTrackIdRef.current = trackId; + let cancelled = false; (async () => { - const bands: EQBandState[] = []; - for (let i = 0; i < 6; i++) { + const parameterReads = Array.from({ length: EQ_BANDS.length * 4 }, (_, index) => + nativeBridge.getChannelStripEQParam(trackId, index), + ); + const [enabledState, phaseState, dcState, parameterValues] = await Promise.all([ + nativeBridge.getChannelStripEQEnabled(trackId), + nativeBridge.getTrackPhaseInvert(trackId), + nativeBridge.getTrackDCOffset(trackId), + Promise.all(parameterReads), + ]); + if (cancelled) return; + setEqEnabled(enabledState); + setPhaseInverted(phaseState); + setDcOffsetEnabled(dcState); + const bands = EQ_BANDS.map((definition, i): EQBandState => { const base = i * 4; - const freq = await nativeBridge.getChannelStripEQParam(trackId, base); - const gain = await nativeBridge.getChannelStripEQParam(trackId, base + 1); - const q = await nativeBridge.getChannelStripEQParam(trackId, base + 2); - const enabled = await nativeBridge.getChannelStripEQParam(trackId, base + 3); - bands.push({ - freq: freq > 0 ? freq : EQ_BANDS[i].defaultFreq, + const freq = parameterValues[base]; + const gain = parameterValues[base + 1]; + const q = parameterValues[base + 2]; + const enabled = parameterValues[base + 3]; + return { + freq: freq > 0 ? freq : definition.defaultFreq, gain, - q: q > 0 ? q : EQ_BANDS[i].defaultQ, + q: q > 0 ? q : definition.defaultQ, enabled: enabled > 0.5, - }); - } + }; + }); setEqBands(bands); })(); + + return () => { + cancelled = true; + }; }, [isOpen, trackId]); // Reset state when modal closes @@ -129,8 +146,11 @@ export function ChannelStripEQModal({ isOpen, onClose }: ChannelStripEQModalProp ); const handlePhaseInvert = useCallback(() => { - setPhaseInverted((p) => !p); - }, []); + if (!trackId) return; + const next = !phaseInverted; + setPhaseInverted(next); + nativeBridge.setTrackPhaseInvert(trackId, next); + }, [phaseInverted, trackId]); const handleDcOffsetToggle = useCallback(() => { if (!trackId) return; @@ -235,14 +255,13 @@ export function ChannelStripEQModal({ isOpen, onClose }: ChannelStripEQModalProp Freq {formatHz(band.freq)} Hz
- { - const hz = linToLogFreq(Number(e.target.value), def.freqRange[0], def.freqRange[1]); + onValueChange={(value) => { + const hz = linToLogFreq(value, def.freqRange[0], def.freqRange[1]); handleEqBandParam(i, 0, hz); }} className="w-full h-1.5 accent-daw-accent cursor-pointer" @@ -261,13 +280,12 @@ export function ChannelStripEQModal({ isOpen, onClose }: ChannelStripEQModalProp {band.gain >= 0 ? "+" : ""}{band.gain.toFixed(1)} dB
- handleEqBandParam(i, 1, Number(e.target.value))} + onValueChange={(value) => handleEqBandParam(i, 1, value)} className="w-full h-1.5 accent-daw-accent cursor-pointer" />
@@ -280,13 +298,12 @@ export function ChannelStripEQModal({ isOpen, onClose }: ChannelStripEQModalProp Q {band.q.toFixed(2)}
- handleEqBandParam(i, 2, Number(e.target.value))} + onValueChange={(value) => handleEqBandParam(i, 2, value)} className="w-full h-1.5 accent-daw-accent cursor-pointer" />
diff --git a/frontend/src/components/ClipPropertiesPanel.tsx b/frontend/src/components/ClipPropertiesPanel.tsx index b518f9d..729ae5a 100644 --- a/frontend/src/components/ClipPropertiesPanel.tsx +++ b/frontend/src/components/ClipPropertiesPanel.tsx @@ -1,4 +1,5 @@ import { X, Lock, Unlock } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useDAWStore } from "../store/useDAWStore"; import { useShallow } from "zustand/shallow"; import { Button, Input, Slider } from "./ui"; @@ -9,19 +10,96 @@ function formatTime(seconds: number): string { return `${m}:${s.padStart(6, "0")}`; } +interface ClipNameFieldProps { + clipId: string; + value: string; +} + +function ClipNameField({ clipId, value }: ClipNameFieldProps) { + const { setClipName } = useDAWStore(useShallow((state) => ({ + setClipName: state.setClipName, + }))); + const [draft, setDraft] = useState(value); + const draftRef = useRef(draft); + const valueRef = useRef(value); + const editingRef = useRef(false); + valueRef.current = value; + + useEffect(() => { + if (editingRef.current) return; + draftRef.current = value; + setDraft(value); + }, [value]); + useEffect(() => () => { + if (!editingRef.current) return; + editingRef.current = false; + const nextName = draftRef.current; + if (nextName !== valueRef.current) { + useDAWStore.getState().setClipName(clipId, nextName); + } + }, [clipId]); + + const beginEdit = useCallback(() => { + editingRef.current = true; + }, []); + const commitEdit = useCallback(() => { + if (!editingRef.current) return; + editingRef.current = false; + const nextName = draftRef.current; + if (nextName !== valueRef.current) setClipName(clipId, nextName); + }, [clipId, setClipName]); + const cancelEdit = useCallback(() => { + if (!editingRef.current) return; + editingRef.current = false; + draftRef.current = valueRef.current; + setDraft(valueRef.current); + }, []); + + return ( + { + draftRef.current = event.target.value; + setDraft(event.target.value); + }} + onBlur={commitEdit} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitEdit(); + event.currentTarget.blur(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelEdit(); + event.currentTarget.blur(); + } + }} + className="mt-0.5" + aria-label="Clip name" + /> + ); +} + /** * ClipPropertiesPanel - Shows and edits properties of the selected clip * Opens with F2 or from View menu */ export function ClipPropertiesPanel() { const { - selectedClipId, tracks, setClipVolume, setClipFades, + selectedClipId, tracks, + beginClipVolumeEdit, setClipVolume, commitClipVolumeEdit, + beginClipFadeEdit, previewClipFades, commitClipFadeEdit, toggleClipMute, toggleClipLock, toggleClipProperties, } = useDAWStore(useShallow((s) => ({ selectedClipId: s.selectedClipId, tracks: s.tracks, + beginClipVolumeEdit: s.beginClipVolumeEdit, setClipVolume: s.setClipVolume, - setClipFades: s.setClipFades, + commitClipVolumeEdit: s.commitClipVolumeEdit, + beginClipFadeEdit: s.beginClipFadeEdit, + previewClipFades: s.previewClipFades, + commitClipFadeEdit: s.commitClipFadeEdit, toggleClipMute: s.toggleClipMute, toggleClipLock: s.toggleClipLock, toggleClipProperties: s.toggleClipProperties, @@ -39,6 +117,39 @@ export function ClipPropertiesPanel() { } } + const clipId = clip?.id; + const getLatestClip = useCallback(() => { + if (!clipId) return undefined; + return useDAWStore.getState().tracks + .flatMap((track) => track.clips) + .find((candidate) => candidate.id === clipId); + }, [clipId]); + const beginVolumeEdit = useCallback(() => { + if (clipId) beginClipVolumeEdit(clipId); + }, [beginClipVolumeEdit, clipId]); + const changeVolume = useCallback((volumeDB: number) => { + if (clipId) setClipVolume(clipId, volumeDB); + }, [clipId, setClipVolume]); + const commitVolumeEdit = useCallback(() => { + if (clipId) commitClipVolumeEdit(clipId); + }, [clipId, commitClipVolumeEdit]); + const beginFadeEdit = useCallback(() => { + if (clipId) beginClipFadeEdit(clipId); + }, [beginClipFadeEdit, clipId]); + const changeFadeIn = useCallback((fadeIn: number) => { + if (!clipId) return; + const latestClip = getLatestClip(); + if (latestClip) previewClipFades(clipId, fadeIn, latestClip.fadeOut); + }, [clipId, getLatestClip, previewClipFades]); + const changeFadeOut = useCallback((fadeOut: number) => { + if (!clipId) return; + const latestClip = getLatestClip(); + if (latestClip) previewClipFades(clipId, latestClip.fadeIn, fadeOut); + }, [clipId, getLatestClip, previewClipFades]); + const commitFadeEdit = useCallback(() => { + if (clipId) commitClipFadeEdit(clipId); + }, [clipId, commitClipFadeEdit]); + return (
{/* Header */} @@ -60,22 +171,7 @@ export function ClipPropertiesPanel() { {/* Name */}
- { - const newName = e.target.value; - useDAWStore.setState((s) => ({ - tracks: s.tracks.map((t) => ({ - ...t, - clips: t.clips.map((c) => - c.id === clip!.id ? { ...c, name: newName } : c, - ), - })), - isModified: true, - })); - }} - className="mt-0.5" - /> +
{/* File Path */} @@ -126,7 +222,10 @@ export function ClipPropertiesPanel() { max={12} step={0.1} value={clip.volumeDB} - onChange={(v) => setClipVolume(clip!.id, v)} + defaultValue={0} + onBeginEdit={beginVolumeEdit} + onChange={changeVolume} + onCommitEdit={commitVolumeEdit} className="mt-1" />
@@ -142,7 +241,10 @@ export function ClipPropertiesPanel() { max={Math.min(clip.duration / 2, 5)} step={0.001} value={clip.fadeIn} - onChange={(v) => setClipFades(clip!.id, v, clip!.fadeOut)} + defaultValue={0} + onBeginEdit={beginFadeEdit} + onChange={changeFadeIn} + onCommitEdit={commitFadeEdit} className="mt-1" />
@@ -155,7 +257,10 @@ export function ClipPropertiesPanel() { max={Math.min(clip.duration / 2, 5)} step={0.001} value={clip.fadeOut} - onChange={(v) => setClipFades(clip!.id, clip!.fadeIn, v)} + defaultValue={0} + onBeginEdit={beginFadeEdit} + onChange={changeFadeOut} + onCommitEdit={commitFadeEdit} className="mt-1" />
diff --git a/frontend/src/components/ColorPicker.tsx b/frontend/src/components/ColorPicker.tsx index 9b1447f..d9d0e2f 100644 --- a/frontend/src/components/ColorPicker.tsx +++ b/frontend/src/components/ColorPicker.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { useDAWStore } from "../store/useDAWStore"; import { useShallow } from "zustand/shallow"; import { Button } from "./ui"; +import { useTransientOverlayShortcutScope } from "../utils/modalShortcutScope"; +import { activateShortcutContext } from "../utils/shortcutContext"; interface ColorPickerProps { currentColor: string; @@ -42,6 +44,7 @@ export function ColorPicker({ recentColors: s.recentColors, addRecentColor: s.addRecentColor, }))); + useTransientOverlayShortcutScope(true, onClose); const handleColorSelect = (color: string) => { addRecentColor(color); @@ -85,18 +88,6 @@ export function ColorPicker({ }; }, [onClose]); - // Close on Escape key - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - const style: React.CSSProperties = position ? { position: "fixed", top: position.top, left: position.left, zIndex: 9999 } : { position: "absolute", left: 4, top: 0, zIndex: 50 }; @@ -107,6 +98,8 @@ export function ColorPicker({ className="bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-2" style={style} onClick={(e) => e.stopPropagation()} + onPointerDownCapture={() => activateShortcutContext({ kind: "modal" })} + onFocusCapture={() => activateShortcutContext({ kind: "modal" })} >
Track Color
diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 9e7d0ac..7a4631f 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -1,9 +1,18 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; -import { getRegisteredActions, ActionDef } from "../store/actionRegistry"; +import { + getDisplayEffectiveShortcut, + getRegisteredActions, + ActionDef, +} from "../store/actionRegistry"; import { useDAWStore } from "../store/useDAWStore"; import { useShallow } from "zustand/react/shallow"; import { guardModalContextMenu } from "../utils/modalEventGuards"; +import { + routeModalShortcutEvent, + useModalShortcutScope, +} from "../utils/modalShortcutScope"; +import { activateShortcutContext } from "../utils/shortcutContext"; interface CommandPaletteProps { isOpen: boolean; @@ -17,10 +26,18 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { const listRef = useRef(null); const actions = useMemo(() => getRegisteredActions(), []); - const { recentActionIds } = useDAWStore(useShallow((state) => ({ + const { + recentActionIds, + keyboardShortcutProfileId, + customShortcuts, + } = useDAWStore(useShallow((state) => ({ recentActionIds: state.recentActions, + keyboardShortcutProfileId: state.keyboardShortcutProfileId, + customShortcuts: state.customShortcuts, }))); + useModalShortcutScope(isOpen, onClose); + const filtered = useMemo(() => { if (!query.trim()) { // Show recent actions first, then all actions @@ -38,9 +55,9 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { (a) => a.name.toLowerCase().includes(lower) || a.category.toLowerCase().includes(lower) || - a.shortcut?.toLowerCase().includes(lower) + getDisplayEffectiveShortcut(a.id)?.toLowerCase().includes(lower) ); - }, [query, actions, recentActionIds]); + }, [query, actions, recentActionIds, keyboardShortcutProfileId, customShortcuts]); // Reset selection when filter changes useEffect(() => { @@ -75,6 +92,12 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { }; const handleKeyDown = (e: React.KeyboardEvent) => { + const modalRoute = routeModalShortcutEvent(e.nativeEvent); + if (modalRoute.result !== "unmatched" || modalRoute.suppressedHeadlessEscape) { + if (modalRoute.result !== "unmatched") e.preventDefault(); + e.stopPropagation(); + return; + } if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1)); @@ -84,9 +107,6 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { } else if (e.key === "Enter" && filtered[selectedIndex]) { e.preventDefault(); executeAction(filtered[selectedIndex]); - } else if (e.key === "Escape") { - e.preventDefault(); - onClose(); } }; @@ -113,6 +133,8 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { data-modal-root="true" onClick={onClose} onContextMenu={guardModalContextMenu} + onPointerDownCapture={() => activateShortcutContext({ kind: "modal" })} + onFocusCapture={() => activateShortcutContext({ kind: "modal" })} > {/* Backdrop */}
@@ -151,6 +173,7 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { {categoryActions.map((action) => { const thisIndex = flatIndex++; const isSelected = thisIndex === selectedIndex; + const effectiveShortcut = getDisplayEffectiveShortcut(action.id); return (
setSelectedIndex(thisIndex)} > {action.name} - {action.shortcut && ( + {effectiveShortcut && ( - {action.shortcut} + {effectiveShortcut} )}
@@ -189,7 +212,9 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { Enter execute - Esc close + + {getDisplayEffectiveShortcut("modal.close") || "Unassigned"} + close
diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx index a99ca62..694157e 100644 --- a/frontend/src/components/ContextMenu.tsx +++ b/frontend/src/components/ContextMenu.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ChevronRight } from "lucide-react"; import { guardModalContextMenu, shouldSuppressWorkspaceContextMenu } from "../utils/modalEventGuards"; +import { useTransientOverlayShortcutScope } from "../utils/modalShortcutScope"; +import { activateShortcutContext } from "../utils/shortcutContext"; export interface MenuItem { label: string; @@ -26,6 +28,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { const submenuCloseTimerRef = useRef | null>(null); const [submenuOpen, setSubmenuOpen] = useState(null); const [adjustedPos, setAdjustedPos] = useState({ x, y }); + useTransientOverlayShortcutScope(true, onClose); const clearSubmenuCloseTimer = () => { if (submenuCloseTimerRef.current) { @@ -71,17 +74,9 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { } }; - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); return () => { document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); }; }, [onClose]); @@ -104,6 +99,8 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { top: adjustedPos.y, }} onContextMenu={guardModalContextMenu} + onPointerDownCapture={() => activateShortcutContext({ kind: "modal" })} + onFocusCapture={() => activateShortcutContext({ kind: "modal" })} > {items.map((item, index) => { if (item.divider) { @@ -156,7 +153,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
{ @@ -167,13 +164,21 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { } }} > - {subItem.swatchColor && ( - +
+ {subItem.icon && {subItem.icon}} + {subItem.swatchColor && ( + + )} + {subItem.label} +
+ {subItem.shortcut && ( + + {subItem.shortcut} + )} - {subItem.label}
))}
diff --git a/frontend/src/components/CorrectPitchModal.tsx b/frontend/src/components/CorrectPitchModal.tsx index c1da976..35db35a 100644 --- a/frontend/src/components/CorrectPitchModal.tsx +++ b/frontend/src/components/CorrectPitchModal.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useShallow } from "zustand/shallow"; import { usePitchEditorStore } from "../store/pitchEditorStore"; import { guardModalContextMenu } from "../utils/modalEventGuards"; +import { ProfiledRangeInput } from "./ui"; const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; @@ -45,10 +46,9 @@ export function CorrectPitchModal() { {pitchCenter}%
- setPitchCenter(Number(e.target.value))} + onValueChange={setPitchCenter} className="w-full h-1 bg-neutral-700 rounded-full appearance-none cursor-pointer accent-daw-accent" />
@@ -63,10 +63,9 @@ export function CorrectPitchModal() { {pitchDrift}%
- setPitchDrift(Number(e.target.value))} + onValueChange={setPitchDrift} className="w-full h-1 bg-neutral-700 rounded-full appearance-none cursor-pointer accent-daw-accent" />
diff --git a/frontend/src/components/CustomKeyboardProfileManager.tsx b/frontend/src/components/CustomKeyboardProfileManager.tsx new file mode 100644 index 0000000..87ab562 --- /dev/null +++ b/frontend/src/components/CustomKeyboardProfileManager.tsx @@ -0,0 +1,191 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import { useShallow } from "zustand/shallow"; +import { getRegisteredActions } from "../store/actionRegistry"; +import { useDAWStore } from "../store/useDAWStore"; +import { MAX_CUSTOM_KEYBOARD_PROFILES } from "../utils/customShortcutProfiles"; +import { Button, Input } from "./ui"; + +export function CustomKeyboardProfileManager() { + const fileInputId = useId(); + const [profileName, setProfileName] = useState(""); + const [status, setStatus] = useState(""); + const { + customKeyboardProfiles, + activeCustomKeyboardProfileId, + createCustomKeyboardProfile, + duplicateKeyboardProfile, + renameCustomKeyboardProfile, + deleteCustomKeyboardProfile, + exportActiveCustomKeyboardProfile, + importCustomKeyboardProfile, + } = useDAWStore(useShallow((state) => ({ + customKeyboardProfiles: state.customKeyboardProfiles, + activeCustomKeyboardProfileId: state.activeCustomKeyboardProfileId, + createCustomKeyboardProfile: state.createCustomKeyboardProfile, + duplicateKeyboardProfile: state.duplicateKeyboardProfile, + renameCustomKeyboardProfile: state.renameCustomKeyboardProfile, + deleteCustomKeyboardProfile: state.deleteCustomKeyboardProfile, + exportActiveCustomKeyboardProfile: state.exportActiveCustomKeyboardProfile, + importCustomKeyboardProfile: state.importCustomKeyboardProfile, + }))); + const activeProfile = customKeyboardProfiles.find( + (profile) => profile.id === activeCustomKeyboardProfileId, + ); + const profileCapacityReached = customKeyboardProfiles.length >= MAX_CUSTOM_KEYBOARD_PROFILES; + const knownActionIds = useMemo( + () => getRegisteredActions().map((action) => action.id), + [], + ); + + useEffect(() => { + setProfileName(activeProfile?.name ?? ""); + }, [activeProfile?.id, activeProfile?.name]); + + const createProfile = () => { + const name = profileName.trim() || "Custom Shortcuts"; + const id = createCustomKeyboardProfile(name); + const savedName = id + ? useDAWStore.getState().customKeyboardProfiles.find((profile) => profile.id === id)?.name + : null; + setStatus(id + ? `Created ${savedName ?? name}.` + : `The profile could not be created. Check the ${MAX_CUSTOM_KEYBOARD_PROFILES}-profile limit and local storage.`); + }; + + const duplicateProfile = () => { + const requestedName = profileName.trim() || undefined; + const id = duplicateKeyboardProfile(requestedName); + const savedName = id + ? useDAWStore.getState().customKeyboardProfiles.find((profile) => profile.id === id)?.name + : null; + setStatus(id + ? `Created and selected ${savedName ?? "a profile copy"}.` + : `The profile copy could not be created. Check the ${MAX_CUSTOM_KEYBOARD_PROFILES}-profile limit and local storage.`); + }; + + const renameProfile = () => { + if (!activeProfile || !profileName.trim()) return; + if (renameCustomKeyboardProfile(activeProfile.id, profileName)) { + setStatus("Profile renamed."); + } else { + setStatus("The profile could not be renamed or saved."); + } + }; + + const deleteProfile = () => { + if (!activeProfile) return; + if (!window.confirm(`Delete the custom profile “${activeProfile.name}”?`)) return; + if (deleteCustomKeyboardProfile(activeProfile.id)) { + setStatus("Custom profile deleted. Its built-in base profile is now active."); + } else { + setStatus("The profile could not be deleted or saved."); + } + }; + + const exportProfile = () => { + if (!activeProfile) return; + const serialized = exportActiveCustomKeyboardProfile(); + if (!serialized) return; + const blob = new Blob([serialized], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${activeProfile.name.replace(/[^A-Za-z0-9_-]+/g, "-") || "openstudio-shortcuts"}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + setStatus("Profile exported."); + }; + + const importProfile = async (file: File | undefined) => { + if (!file) return; + if (file.size > 1_000_000) { + setStatus("The selected profile is larger than 1 MB."); + return; + } + try { + const serialized = await file.text(); + const result = importCustomKeyboardProfile(serialized, knownActionIds); + setStatus(result.success + ? `Imported and selected ${result.profile.name}.` + : result.error); + } catch { + setStatus("OpenStudio could not read the selected profile file."); + } + }; + + return ( +
+
+
+

+ Custom keyboard profiles +

+

+ Named profiles keep multiple bindings and separate macOS, Windows, Linux, and fallback overrides. +

+
+
+ setProfileName(event.target.value)} + placeholder={activeProfile ? activeProfile.name : "Profile name"} + aria-label="Profile name" + maxLength={64} + size="sm" + fullWidth + className="min-w-0 flex-1" + /> +
+ + + + + + +
+
+

+ {status} +

+
+
+ ); +} diff --git a/frontend/src/components/EnvelopeManagerModal.tsx b/frontend/src/components/EnvelopeManagerModal.tsx index 82f39e4..e2bbe49 100644 --- a/frontend/src/components/EnvelopeManagerModal.tsx +++ b/frontend/src/components/EnvelopeManagerModal.tsx @@ -2,16 +2,16 @@ import { useState, useEffect, useMemo } from "react"; import { useShallow } from "zustand/shallow"; import { ChevronDown, ChevronRight, Search } from "lucide-react"; import { useDAWStore, type AutomationWriteBehavior } from "../store/useDAWStore"; -import { nativeBridge } from "../services/NativeBridge"; +import { nativeBridge, type PluginParameterInfo } from "../services/NativeBridge"; import { Modal } from "./ui"; -import { getTrackAutomationParams, getMasterAutomationParams, pluginAutomationParamId } from "../store/automationParams"; +import { builtInAutomationParamId, getTrackAutomationParams, getMasterAutomationParams, pluginAutomationParamId } from "../store/automationParams"; +import { + activateShortcutContext, + getActiveShortcutContext, + registerShortcutSurface, +} from "../utils/shortcutContext"; -interface PluginParam { - index: number; - name: string; - value: number; - text: string; -} +type PluginParam = PluginParameterInfo; interface FXSlotInfo { index: number; @@ -97,6 +97,18 @@ export function EnvelopeManagerModal() { const [pluginParams, setPluginParams] = useState>(new Map()); const [loading, setLoading] = useState(false); + useEffect(() => { + if (!showEnvelopeManager) return; + const fallback = getActiveShortcutContext(); + const unregister = registerShortcutSurface( + { kind: "automation" }, + () => "unmatched", + fallback, + ); + activateShortcutContext({ kind: "automation" }); + return unregister; + }, [showEnvelopeManager]); + // Fetch FX chain + plugin params on open useEffect(() => { if (!showEnvelopeManager || !envelopeManagerTrackId) return; @@ -183,7 +195,9 @@ export function EnvelopeManagerModal() { const fxCategory = fx.isInputFX ? `Input FX: ${fx.name}` : `FX: ${fx.name}`; for (const param of params) { - const paramId = pluginAutomationParamId(fx.isInputFX, fx.index, param.index); + const paramId = param.builtIn && param.paramId + ? builtInAutomationParamId(fx.isInputFX, fx.index, param.paramId) + : pluginAutomationParamId(fx.isInputFX, fx.index, param.index); const lane = automationLanes.find((l) => l.param === paramId); rows.push({ paramId, @@ -225,12 +239,27 @@ export function EnvelopeManagerModal() { // Handlers — dispatch to master or track actions const trackId = envelopeManagerTrackId!; + const selectLane = (laneId: string | null) => { + if (!laneId) return; + activateShortcutContext({ kind: "automation" }); + const state = useDAWStore.getState(); + if (isMaster) state.setSelectedAutomationLane({ kind: "master", laneId }); + else state.setSelectedAutomationLane({ kind: "track", trackId, laneId }); + }; + const ensureLane = (row: EnvelopeRow): string | null => { - if (row.laneId) return row.laneId; + if (row.laneId) { + selectLane(row.laneId); + return row.laneId; + } + let laneId: string | null; if (isMaster) { - return addMasterAutomationLane(row.paramId); + laneId = addMasterAutomationLane(row.paramId); + } else { + laneId = addAutomationLane(trackId, row.paramId, row.label); } - return addAutomationLane(trackId, row.paramId, row.label); + selectLane(laneId); + return laneId; }; const handleToggleVisible = (row: EnvelopeRow) => { @@ -283,6 +312,13 @@ export function EnvelopeManagerModal() { return ( +
activateShortcutContext({ kind: "automation" })} + onContextMenuCapture={() => activateShortcutContext({ kind: "automation" })} + onFocusCapture={() => activateShortcutContext({ kind: "automation" })} + > {/* Top controls */}
@@ -354,6 +390,9 @@ export function EnvelopeManagerModal() {
selectLane(row.laneId)} + onFocus={() => selectLane(row.laneId)} > {row.label} @@ -416,6 +455,7 @@ export function EnvelopeManagerModal() {
)}
+
); } diff --git a/frontend/src/components/EssentialControlsCard.tsx b/frontend/src/components/EssentialControlsCard.tsx index 293b96c..1815b2a 100644 --- a/frontend/src/components/EssentialControlsCard.tsx +++ b/frontend/src/components/EssentialControlsCard.tsx @@ -1,8 +1,12 @@ import { useMemo, useState } from "react"; import { useShallow } from "zustand/shallow"; import { BookOpen, HelpCircle, MousePointer2, X } from "lucide-react"; -import { getEffectiveActionShortcut } from "../store/actionRegistry"; import { useDAWStore } from "../store/useDAWStore"; +import { + getEffectiveShortcutLabel, + getTimelineWheelHelp, +} from "../utils/inputProfileHelp"; +import { getShortcutPlatform } from "../utils/platform"; import { Button } from "./ui"; const LS_KEY = "openstudio_essentialControlsDismissed"; @@ -11,44 +15,42 @@ export function EssentialControlsCard() { const { showContextualHelp, showGettingStarted, + inputProfileOnboardingSeen, toggleContextualHelp, toggleGettingStarted, + customShortcuts, + keyboardShortcutProfileId, + mouseBehaviorProfileId, } = useDAWStore( useShallow((state) => ({ showContextualHelp: state.showContextualHelp, showGettingStarted: state.showGettingStarted, + inputProfileOnboardingSeen: state.inputProfileOnboardingSeen, toggleContextualHelp: state.toggleContextualHelp, toggleGettingStarted: state.toggleGettingStarted, + customShortcuts: state.customShortcuts, + keyboardShortcutProfileId: state.keyboardShortcutProfileId, + mouseBehaviorProfileId: state.mouseBehaviorProfileId, })), ); - const customShortcuts = useDAWStore((state) => state.customShortcuts); const [dismissed, setDismissed] = useState( () => localStorage.getItem(LS_KEY) === "true", ); - const helpShortcut = useMemo( - () => getEffectiveActionShortcut("help.contextualHelp") ?? "F1", - [customShortcuts], - ); - const playShortcut = useMemo( - () => getEffectiveActionShortcut("transport.play") ?? "Space", - [customShortcuts], - ); - const recordShortcut = useMemo( - () => getEffectiveActionShortcut("transport.record") ?? "Ctrl+R", - [customShortcuts], - ); - const addTrackShortcut = useMemo( - () => getEffectiveActionShortcut("insert.audioTrack") ?? "Ctrl+T", - [customShortcuts], - ); - const mixerShortcut = useMemo( - () => getEffectiveActionShortcut("view.toggleMixer") ?? "Ctrl+M", - [customShortcuts], + const shortcuts = useMemo(() => ({ + help: getEffectiveShortcutLabel("help.contextualHelp", "F1"), + play: getEffectiveShortcutLabel("transport.play", "Space"), + record: getEffectiveShortcutLabel("transport.record", "Ctrl+R"), + addTrack: getEffectiveShortcutLabel("insert.audioTrack", "Ctrl+T"), + mixer: getEffectiveShortcutLabel("view.toggleMixer", "Ctrl+M"), + }), [customShortcuts, keyboardShortcutProfileId]); + const wheelHelp = useMemo( + () => getTimelineWheelHelp(mouseBehaviorProfileId, getShortcutPlatform(), 4), + [mouseBehaviorProfileId], ); - if (dismissed || showContextualHelp || showGettingStarted) { + if (!inputProfileOnboardingSeen || dismissed || showContextualHelp || showGettingStarted) { return null; } @@ -58,13 +60,16 @@ export function EssentialControlsCard() { }; return ( -
+ ); } diff --git a/frontend/src/components/FXChainPanel.css b/frontend/src/components/FXChainPanel.css index 67f4ba4..9e908df 100644 --- a/frontend/src/components/FXChainPanel.css +++ b/frontend/src/components/FXChainPanel.css @@ -1,4 +1,4 @@ -.fx-chain-overlay { +.fx-chain-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.8); @@ -227,6 +227,58 @@ border-radius: 0 0 6px 6px; } +.plugin-editor-window-app { + width: 100vw; + height: 100vh; + overflow: clip; + color: #f8fafc; + background: #050505; +} + +.plugin-editor-window-app .builtin-plugin-panel { + width: 100%; + height: 100%; + margin: 0; + border: 0; + border-radius: 0; + overflow: auto; + background: #050505; +} + +.plugin-editor-window-app .builtin-plugin-panel:not([data-kind="nam"]) { + padding: 14px; +} + +.plugin-editor-window-app .builtin-plugin-panel[data-kind="nam"] .builtin-panel-header { + position: sticky; + top: 0; + z-index: 20; +} + +.plugin-editor-window-app .nam-product { + min-height: calc(100vh - 45px); + padding: 16px; +} + +.plugin-editor-empty { + display: grid; + min-height: 100vh; + place-items: center; + padding: 32px; + color: #cbd5e1; + background: #050505; +} + +.plugin-editor-empty > div { + display: grid; + gap: 8px; + max-width: 460px; + padding: 20px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + background: rgba(15, 23, 42, 0.72); +} + .builtin-panel-header { display: flex; align-items: center; @@ -251,11 +303,58 @@ } .builtin-panel-title span { - overflow: hidden; + overflow: clip; text-overflow: ellipsis; white-space: nowrap; } +.builtin-window-controls { + display: inline-grid; + grid-template-columns: repeat(3, 38px); + align-items: stretch; + justify-content: end; + align-self: stretch; + margin-block: -9px; + margin-right: -10px; +} + +.builtin-window-controls span, +.builtin-window-controls button { + min-width: 0; + height: 100%; + display: grid; + place-items: center; + border: 0; + color: rgba(226, 232, 240, 0.84); + background: transparent; +} + +.builtin-window-controls span::before { + content: ""; + display: block; +} + +.builtin-window-control-min::before { + width: 10px; + height: 1px; + background: currentColor; +} + +.builtin-window-control-max::before { + width: 10px; + height: 10px; + border: 1px solid currentColor; +} + +.builtin-window-controls button { + cursor: pointer; +} + +.builtin-window-controls button:hover { + color: #ffffff; + background: rgba(239, 68, 68, 0.84); +} + .builtin-visual { width: 100%; height: 126px; @@ -673,193 +772,4502 @@ font-variant-numeric: tabular-nums; } -@media (max-width: 900px) { - .fx-chain-panel-two-column { - width: 98%; - height: 92vh; - } +.builtin-plugin-panel[data-kind="nam"] { + padding: 0; + overflow: hidden; + border-color: #49412d; + background: #050505; +} - .fx-chain-two-column-content { - grid-template-columns: 1fr; - grid-template-rows: minmax(260px, 44vh) minmax(0, 1fr); - } +.plugin-editor-window-app .builtin-plugin-panel[data-kind="nam"] { + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; +} - .fx-chain-loaded-column { - border-right: 0; - border-bottom: 1px solid #404040; - } +.builtin-plugin-panel[data-kind="nam"] .builtin-panel-header { + margin: 0; + min-height: 42px; + padding: 0 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(0, 0, 0, 0.08)), + #0c0d0f; +} - .builtin-param-grid, - .builtin-macro-strip { - grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); - } +.builtin-plugin-panel[data-kind="nam"] .builtin-panel-title { + gap: 8px; + color: #f8fafc; + font-size: 16px; + font-weight: 800; } -@media (max-width: 520px) { - .builtin-plugin-panel { - padding: 8px; - } +.builtin-plugin-panel[data-kind="nam"] .builtin-panel-title svg { + width: 21px; + height: 21px; + color: rgba(248, 250, 252, 0.9); + stroke-width: 1.6; +} - .builtin-visual { - height: 112px; - } +.nam-product { + position: relative; + display: grid; + gap: 10px; + padding: 10px; + color: #f1f5f9; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(0, 0, 0, 0.15)), + #090908; +} - .builtin-param-grid, - .builtin-macro-strip { - grid-template-columns: 1fr; - } +.nam-product button, +.nam-product select, +.nam-product input { + font: inherit; } -.builtin-empty { - padding: 16px 8px; - color: #737373; - font-size: 11px; - text-align: center; +.nam-product-topbar, +.nam-chain, +.nam-rig-stage, +.nam-rack-control-band, +.nam-browser-drawer { + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + background: linear-gradient(180deg, rgba(34, 34, 32, 0.98), rgba(13, 13, 12, 0.98)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); } -.fx-slot-name:hover { - color: #2563eb; +.nam-product-topbar { + display: grid; + grid-template-columns: minmax(190px, 1.25fr) minmax(180px, 1fr) auto minmax(92px, 0.5fr) minmax(92px, 0.5fr); + align-items: center; + gap: 10px; + padding: 10px; } -.fx-remove-btn { - width: 24px; - height: 24px; +.nam-brand-block, +.nam-browser-title, +.nam-rack-section-title, +.nam-auth-head, +.nam-toolbar, +.nam-stats, +.nam-view-actions, +.nam-detail-head { display: flex; align-items: center; - justify-content: center; - background: transparent; - border: 1px solid #404040; - border-radius: 4px; - color: #737373; - font-size: 18px; - cursor: pointer; - transition: all 0.2s; + gap: 8px; } -.fx-remove-btn:hover { - background: #dc2626; - border-color: #dc2626; - color: white; +.nam-brand-mark { + display: grid; + width: 38px; + height: 38px; + flex: 0 0 auto; + place-items: center; + border: 1px solid rgba(236, 196, 110, 0.38); + border-radius: 50%; + color: #f5c86a; + background: + radial-gradient(circle at 50% 45%, rgba(245, 200, 106, 0.24), rgba(10, 10, 10, 0.16) 54%), + #141414; } -/* Empty state */ -.fx-empty-state { - text-align: center; - padding: 60px 20px; - color: #737373; +.nam-brand-block strong, +.nam-browser-title strong, +.nam-rack-section-title span, +.nam-detail-head strong { + display: block; + color: #f8fafc; + font-size: 12px; + font-weight: 800; +} + +.nam-brand-block small, +.nam-browser-title small, +.nam-detail-head span, +.nam-result-copy small, +.nam-result-copy span, +.nam-auth-copy, +.nam-auth-library, +.nam-status, +.nam-rack-slots span { + color: rgba(226, 232, 240, 0.58); + font-size: 11px; } -.fx-empty-state p { - margin: 8px 0; +.nam-preset-select { + min-width: 0; + min-height: 36px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 0 10px; + border: 1px solid rgba(236, 196, 110, 0.28); + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); + color: #f5c86a; } -.fx-empty-state .hint { - font-size: 13px; - color: #525252; +.nam-preset-select select, +.nam-rack-control-select select, +.nam-filters select { + min-width: 0; + width: 100%; + height: 28px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + background: #121212; + color: #f8fafc; + outline: none; } -/* Old single-column styles (kept for backward compatibility) */ -.fx-chain-panel { - background: #171717; - border: 1px solid #404040; - border-radius: 8px; - width: 90%; - max-width: 600px; - max-height: 80vh; - display: flex; - flex-direction: column; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +.nam-preset-dirty { + grid-column: 1 / -1; + width: fit-content; + padding: 1px 6px; + border: 1px solid rgba(245, 200, 106, 0.28); + border-radius: 999px; + color: #f5c86a; + background: rgba(245, 200, 106, 0.08); + font-size: 10px; + font-weight: 900; } -.fx-chain-content { - flex: 1; - overflow-y: auto; - padding: 16px; - background: #0a0a0a; +.nam-preset-manager { + position: absolute; + z-index: 12; + top: 76px; + left: clamp(14px, 24vw, 360px); + right: 14px; + max-width: 860px; + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid rgba(245, 200, 106, 0.28); + border-radius: 8px; + background: + radial-gradient(circle at 14% 0%, rgba(245, 200, 106, 0.12), transparent 34%), + linear-gradient(180deg, rgba(28, 28, 25, 0.98), rgba(7, 7, 6, 0.98)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.055), + 0 28px 70px rgba(0, 0, 0, 0.5); } -.fx-slots { - display: flex; - flex-direction: column; - gap: 8px; +.nam-preset-manager-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; } -.fx-slot { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px; - background: #1a1a1a; - border: 1px solid #404040; - border-radius: 6px; - transition: all 0.2s; +.nam-preset-manager-head > div { + min-width: 0; + display: grid; + gap: 2px; } -.fx-slot:hover { - border-color: #2563eb; - background: #1f1f1f; +.nam-preset-manager-head span, +.nam-preset-column > span { + color: #8ef5c2; + font-size: 10px; + font-weight: 900; + text-transform: uppercase; } -.fx-info { - flex: 1; +.nam-preset-manager-head strong { + min-width: 0; + overflow: hidden; + color: #f8fafc; + font-size: 18px; + line-height: 1.12; + text-overflow: ellipsis; + white-space: nowrap; } -.fx-name { - font-weight: 600; - color: white; - margin-bottom: 4px; +.nam-preset-manager-head > button, +.nam-preset-save-row button, +.nam-preset-transfer-row button, +.nam-user-preset-row > button:last-child { + min-height: 30px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.045); + color: rgba(248, 250, 252, 0.78); + cursor: pointer; } -.fx-index { - font-size: 12px; - color: #737373; +.nam-preset-manager-head > button { + width: 30px; + height: 30px; + display: grid; + place-items: center; + font-size: 16px; } -.fx-controls { - display: flex; +.nam-preset-search, +.nam-preset-save-row, +.nam-preset-transfer-row { + display: grid; + align-items: center; gap: 8px; } -.fx-btn { - padding: 6px 14px; - border: none; - border-radius: 4px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; +.nam-preset-search { + grid-template-columns: auto minmax(0, 1fr); + padding: 0 9px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + color: rgba(226, 232, 240, 0.58); + background: rgba(0, 0, 0, 0.22); } -.edit-btn { - background: #2563eb; - color: white; +.nam-preset-filter-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; } -.edit-btn:hover { - background: #1d4ed8; +.nam-preset-filter-row button { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 999px; + color: rgba(226, 232, 240, 0.7); + background: rgba(255, 255, 255, 0.035); + font-size: 10px; + font-weight: 850; + cursor: pointer; } -.bypass-btn { - background: #f59e0b; - color: white; +.nam-preset-filter-row button small { + min-width: 16px; + padding: 1px 5px; + border-radius: 999px; + color: rgba(7, 7, 6, 0.9); + background: rgba(142, 245, 194, 0.72); + font-size: 9px; + font-weight: 950; } -.bypass-btn:hover { - background: #d97706; +.nam-preset-filter-row button[data-active="true"] { + border-color: rgba(142, 245, 194, 0.36); + color: #8ef5c2; + background: rgba(69, 179, 107, 0.12); } -.remove-btn { - background: #dc2626; - color: white; +.nam-preset-save-row { + grid-template-columns: minmax(0, 1fr) auto; } -.remove-btn:hover { - background: #b91c1c; +.nam-preset-save-fields { + min-width: 0; + display: grid; + grid-template-columns: minmax(150px, 1.25fr) minmax(110px, 0.65fr) minmax(140px, 1fr) minmax(150px, 1fr); + gap: 7px; } -.close-btn-small { - background: #404040; - color: white; +.nam-preset-transfer-row { + grid-template-columns: repeat(2, minmax(0, max-content)); + justify-content: end; +} + +.nam-preset-search input, +.nam-preset-save-row input { + min-width: 0; + height: 34px; + border: 0; + background: transparent; + color: #f8fafc; + outline: none; +} + +.nam-preset-save-row input { + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); +} + +.nam-preset-save-row button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 11px; + border-color: rgba(142, 245, 194, 0.34); + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.24), rgba(10, 14, 12, 0.92)), + #101211; + color: #d1fae5; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 0 0 1px rgba(142, 245, 194, 0.08); + font-weight: 900; +} + +.nam-preset-save-row button:hover:not(:disabled) { + border-color: rgba(142, 245, 194, 0.52); + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.34), rgba(10, 14, 12, 0.95)), + #101211; +} + +.nam-preset-transfer-row button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 30px; + padding: 0 10px; + border-color: rgba(142, 245, 194, 0.18); + color: rgba(226, 232, 240, 0.76); + background: rgba(255, 255, 255, 0.035); + font-size: 11px; + font-weight: 850; +} + +.nam-preset-transfer-row button:hover:not(:disabled) { + border-color: rgba(142, 245, 194, 0.36); + color: #8ef5c2; + background: rgba(69, 179, 107, 0.1); +} + +.nam-preset-status { + margin: 0; + padding: 6px 8px; + border: 1px solid rgba(142, 245, 194, 0.16); + border-radius: 6px; + color: rgba(226, 232, 240, 0.76); + background: rgba(69, 179, 107, 0.07); + font-size: 11px; +} + +.nam-preset-manager-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + min-height: 0; +} + +.nam-preset-column { + min-width: 0; + display: grid; + align-content: start; + gap: 7px; + padding: 9px; + border: 1px solid rgba(255, 255, 255, 0.075); + border-radius: 8px; + background: rgba(0, 0, 0, 0.18); +} + +.nam-preset-list { + display: grid; + gap: 6px; + max-height: min(310px, 42vh); + overflow: auto; +} + +.nam-preset-list > button, +.nam-user-preset-row > button:first-child { + min-width: 0; + display: grid; + gap: 3px; + padding: 9px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.018)); + color: rgba(226, 232, 240, 0.78); + text-align: left; + cursor: pointer; +} + +.nam-preset-list > button[data-active="true"] { + border-color: rgba(245, 200, 106, 0.46); + background: + linear-gradient(90deg, rgba(245, 200, 106, 0.14), rgba(69, 179, 107, 0.07)), + rgba(255, 255, 255, 0.035); +} + +.nam-preset-list strong { + min-width: 0; + overflow: hidden; + color: #f8fafc; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-preset-list small, +.nam-preset-list em { + min-width: 0; + overflow: hidden; + color: rgba(226, 232, 240, 0.56); + font-size: 11px; + font-style: normal; + line-height: 1.28; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-user-preset-row { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(8, 30px); + gap: 6px; +} + +.nam-user-preset-row > button:not(:first-child) { + display: grid; + width: 30px; + min-height: 30px; + place-items: center; +} + +.nam-user-preset-row[data-favorite="true"] > button:nth-child(2) { + border-color: rgba(245, 200, 106, 0.4); + color: #f5c86a; + background: rgba(245, 200, 106, 0.12); +} + +.nam-user-preset-row > button:first-child span { + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.nam-user-preset-row > button:first-child em { + max-width: 96px; + padding: 2px 6px; + border: 1px solid rgba(142, 245, 194, 0.14); + border-radius: 999px; + color: rgba(226, 232, 240, 0.74); + background: rgba(0, 0, 0, 0.2); + font-size: 10px; + font-style: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-top-actions { + display: flex; + align-items: center; + gap: 5px; +} + +.nam-top-actions button, +.nam-chain-module, +.nam-shelves button, +.nam-tabs button, +.nam-favorite { + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.045); + color: rgba(248, 250, 252, 0.72); + cursor: pointer; +} + +.nam-top-actions button { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 6px; + font-size: 11px; + font-weight: 800; +} + +.nam-top-actions button:disabled { + cursor: default; + opacity: 0.42; +} + +.nam-top-actions button:disabled svg { + color: rgba(226, 232, 240, 0.44); +} + +.nam-top-actions .nam-models-button { + width: auto; + min-width: 78px; + grid-auto-flow: column; + grid-auto-columns: max-content; + gap: 6px; + padding: 0 10px; + color: #8ef5c2; +} + +.nam-top-actions .nam-models-button:hover { + border-color: rgba(142, 245, 194, 0.42); + background: rgba(69, 179, 107, 0.12); +} + +.nam-top-actions button[data-active="true"] { + border-color: rgba(236, 196, 110, 0.52); + color: #f5c86a; + background: rgba(236, 196, 110, 0.13); +} + +.nam-top-actions button[data-dirty="true"] { + position: relative; + box-shadow: inset 0 -2px 0 rgba(245, 200, 106, 0.72); +} + +.nam-mini-meter { + min-width: 0; + display: grid; + gap: 5px; +} + +.nam-mini-meter span { + color: rgba(226, 232, 240, 0.62); + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0; +} + +.nam-mini-meter i { + position: relative; + height: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 999px; + background: #060606; +} + +.nam-mini-meter i::after { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: var(--nam-meter-pct); + border-radius: inherit; + background: linear-gradient(90deg, #45b36b, #f5c86a 72%, #e45858); +} + +.nam-mini-meter i > b { + position: absolute; + top: -2px; + bottom: -2px; + left: clamp(0%, var(--nam-meter-peak-pct), 100%); + width: 2px; + border-radius: 999px; + background: rgba(248, 250, 252, 0.8); + transform: translateX(-1px); + box-shadow: 0 0 8px rgba(248, 250, 252, 0.34); +} + +.nam-mini-meter[data-silent="true"] i > b { + opacity: 0; +} + +.nam-mini-meter[data-clip="true"] i { + border-color: rgba(248, 113, 113, 0.74); + box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.18); +} + +.nam-mini-meter[data-clip="true"] strong { + color: #fb7185; +} + +.nam-meter-trim { + min-width: 150px; + cursor: ns-resize; + user-select: none; +} + +.nam-meter-trim[data-enabled="false"] { + cursor: default; + opacity: 0.72; +} + +.nam-meter-trim:focus-visible { + outline: 2px solid rgba(142, 245, 194, 0.72); + outline-offset: 4px; +} + +.nam-meter-trim em { + width: fit-content; + min-width: 54px; + padding: 2px 7px; + border: 1px solid rgba(142, 245, 194, 0.16); + border-radius: 999px; + color: #d1fae5; + background: rgba(0, 0, 0, 0.38); + font-size: 10px; + font-style: normal; + font-weight: 850; +} + +.nam-meter-trim:hover i { + border-color: rgba(142, 245, 194, 0.38); +} + +.nam-chain { + display: grid; + grid-template-columns: minmax(118px, 0.8fr) minmax(0, 5fr); + gap: 8px; + align-items: stretch; + padding: 8px; +} + +.nam-chain-meta { + position: relative; + z-index: 1; + min-width: 0; + display: grid; + align-content: center; + gap: 4px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.075); + border-radius: 7px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(0, 0, 0, 0.18)), + rgba(0, 0, 0, 0.18); +} + +.nam-chain-meta span { + color: #8ef5c2; + font-size: 10px; + font-weight: 900; + text-transform: uppercase; +} + +.nam-chain-meta strong { + color: rgba(226, 232, 240, 0.72); + font-size: 10px; + font-weight: 750; + line-height: 1.25; +} + +.nam-chain-meta button { + width: fit-content; + min-height: 24px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 7px; + border: 1px solid rgba(245, 200, 106, 0.16); + border-radius: 6px; + background: rgba(245, 200, 106, 0.06); + color: #f8fafc; + font-size: 10px; + font-weight: 850; +} + +.nam-chain-meta button:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +.nam-chain-meta button[data-active="true"] { + border-color: rgba(142, 245, 194, 0.36); + background: rgba(69, 179, 107, 0.13); + color: #8ef5c2; +} + +.nam-chain-slots { + min-width: 0; + display: grid; + grid-template-columns: repeat(8, minmax(74px, 1fr)); + gap: 6px; +} + +.nam-chain-module { + position: relative; + min-width: 0; + min-height: 78px; + overflow: hidden; + border-radius: 7px; + cursor: pointer; +} + +.nam-chain-module[data-active="true"] { + border-color: rgba(69, 179, 107, 0.46); + background: rgba(69, 179, 107, 0.1); +} + +.nam-chain-module[data-selected="true"] { + border-color: rgba(255, 216, 122, 0.9); + background: + radial-gradient(circle at 20% 0%, rgba(245, 200, 106, 0.24), transparent 42%), + linear-gradient(180deg, rgba(54, 43, 22, 0.98), rgba(13, 12, 9, 0.98)); + box-shadow: + inset 0 0 0 2px rgba(255, 216, 122, 0.48), + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 0 0 1px rgba(245, 200, 106, 0.34), + 0 0 30px rgba(245, 200, 106, 0.18); +} + +.nam-chain-module[data-selected="true"]::after { + content: "Selected"; + position: absolute; + right: 30px; + bottom: 7px; + z-index: 1; + color: #ffe3a1; + font-size: 8px; + font-weight: 950; + letter-spacing: 0; + text-transform: uppercase; + pointer-events: none; +} + +.nam-chain-module:focus-visible { + outline: 2px solid rgba(142, 245, 194, 0.7); + outline-offset: 2px; +} + +.nam-chain-module[data-favorite="true"] { + box-shadow: + inset 0 0 0 1px rgba(255, 213, 110, 0.12), + 0 0 0 1px rgba(255, 213, 110, 0.06); +} + +.nam-chain-module[data-planned="true"] { + border-style: dashed; + opacity: 0.74; +} + +.nam-chain-module[data-dragging="true"] { + opacity: 0.6; +} + +.nam-chain-module[data-drop-target="true"] { + outline: 1px dashed rgba(245, 200, 106, 0.5); + outline-offset: -3px; +} + +.nam-chain-module[data-drop-target="true"][data-drop-allowed="true"] { + background: rgba(245, 200, 106, 0.09); +} + +.nam-chain-module[data-drop-target="true"][data-drop-allowed="false"] { + outline-color: rgba(248, 113, 113, 0.42); +} + +.nam-chain-grip { + position: absolute; + top: 5px; + left: 5px; + z-index: 2; + display: grid; + width: 19px; + height: 23px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 5px; + color: rgba(226, 232, 240, 0.52); + background: rgba(0, 0, 0, 0.28); + cursor: grab; +} + +.nam-chain-grip:active { + cursor: grabbing; +} + +.nam-chain-module-main { + width: 100%; + min-height: 54px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 8px 32px 19px 25px; + border: 0; + border-radius: inherit; + background: transparent; + color: inherit; + text-align: left; +} + +.nam-chain-module-main:hover { + background: rgba(255, 255, 255, 0.035); +} + +.nam-chain-slot-actions { + position: absolute; + right: 5px; + bottom: 5px; + left: 5px; + z-index: 2; + display: flex; + justify-content: flex-end; + gap: 3px; + pointer-events: none; +} + +.nam-chain-slot-actions button { + width: 21px; + height: 19px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.085); + border-radius: 5px; + background: rgba(0, 0, 0, 0.38); + color: rgba(226, 232, 240, 0.64); + pointer-events: auto; +} + +.nam-chain-slot-actions button:hover:not(:disabled) { + border-color: rgba(245, 200, 106, 0.34); + color: #f5c86a; +} + +.nam-chain-slot-actions button:disabled { + opacity: 0.22; + cursor: not-allowed; +} + +.nam-chain-favorite { + position: absolute; + top: 31px; + right: 8px; + z-index: 2; + display: grid; + width: 16px; + height: 16px; + place-items: center; + border-radius: 50%; + color: #ffd56e; + background: rgba(0, 0, 0, 0.42); + pointer-events: none; +} + +.nam-chain-power { + position: absolute; + top: 5px; + right: 5px; + width: 23px; + height: 23px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 50%; + background: rgba(0, 0, 0, 0.36); + color: rgba(226, 232, 240, 0.58); +} + +.nam-chain-power[data-active="true"] { + border-color: rgba(142, 245, 194, 0.44); + background: rgba(69, 179, 107, 0.18); + color: #8ef5c2; +} + +.nam-chain-power:hover:not(:disabled) { + border-color: rgba(245, 200, 106, 0.42); + color: #f5c86a; +} + +.nam-chain-power:disabled { + opacity: 0.38; + cursor: not-allowed; +} + +.nam-chain-icon { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 6px; + background: rgba(0, 0, 0, 0.34); +} + +.nam-chain-module strong, +.nam-result-copy strong, +.nam-rack-slots strong { + display: block; + min-width: 0; + overflow: hidden; + color: #f8fafc; + font-size: 12px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-chain-module small { + display: block; + min-width: 0; + overflow: hidden; + color: rgba(226, 232, 240, 0.56); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-slot-browser { + display: grid; + grid-template-columns: minmax(120px, 0.92fr) minmax(0, 4fr); + gap: 10px; + max-height: 168px; + overflow: hidden; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + background: + radial-gradient(circle at 6% 0%, rgba(142, 245, 194, 0.08), transparent 32%), + linear-gradient(180deg, rgba(31, 31, 29, 0.98), rgba(9, 9, 8, 0.98)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); +} + +.nam-slot-browser-tabs { + display: grid; + gap: 5px; + align-content: start; + overflow: auto; +} + +.nam-slot-browser-tabs button, +.nam-slot-browser-actions button { + min-height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 7px; + color: rgba(226, 232, 240, 0.78); + background: rgba(255, 255, 255, 0.045); + font-size: 11px; + font-weight: 850; +} + +.nam-slot-browser-tabs button[data-active="true"], +.nam-slot-browser-actions button[data-active="true"] { + border-color: rgba(142, 245, 194, 0.42); + color: #8ef5c2; + background: rgba(69, 179, 107, 0.13); +} + +.nam-slot-browser-grid { + min-width: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 8px; + overflow: auto; +} + +.nam-slot-browser-card { + min-width: 0; + display: grid; + gap: 8px; + padding: 11px; + border: 1px solid rgba(255, 255, 255, 0.095); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(245, 200, 106, 0.055), transparent 38%), + rgba(0, 0, 0, 0.22); +} + +.nam-slot-browser-card[data-active="true"] { + border-color: rgba(69, 179, 107, 0.34); + background: + linear-gradient(135deg, rgba(69, 179, 107, 0.12), transparent 42%), + rgba(0, 0, 0, 0.22); +} + +.nam-slot-browser-card[data-favorite="true"] { + border-color: rgba(255, 213, 110, 0.28); +} + +.nam-slot-browser-card-head { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + gap: 8px; + align-items: center; +} + +.nam-slot-browser-card-head strong { + overflow: hidden; + color: #f8fafc; + font-size: 13px; + line-height: 1.1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-slot-browser-card-head small, +.nam-slot-browser-copy { + overflow: hidden; + color: rgba(148, 163, 184, 0.8); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-slot-browser-card p, +.nam-slot-browser-status { + margin: 0; + color: rgba(203, 213, 225, 0.72); + font-size: 12px; + line-height: 1.36; +} + +.nam-slot-browser-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.nam-slot-browser-actions button { + min-height: 28px; + padding: 0 9px; +} + +.nam-slot-browser-actions button:hover { + border-color: rgba(245, 200, 106, 0.38); + color: #f8fafc; +} + +.nam-slot-browser-status { + grid-column: 2; + padding: 7px 9px; + border: 1px solid rgba(142, 245, 194, 0.18); + border-radius: 7px; + color: #8ef5c2; + background: rgba(69, 179, 107, 0.08); +} + +.nam-rack-error { + padding: 8px 10px; + border: 1px solid rgba(239, 68, 68, 0.35); + border-radius: 7px; + background: rgba(127, 29, 29, 0.32); + color: #fecaca; + font-size: 12px; +} + +.nam-rack-input-diagnostics { + display: grid; + grid-template-columns: minmax(180px, 0.72fr) minmax(260px, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 9px 10px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + background: linear-gradient(180deg, rgba(20, 20, 18, 0.96), rgba(9, 9, 8, 0.96)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.nam-rack-input-diagnostics[data-tone="success"] { + border-color: rgba(74, 222, 128, 0.24); + background: linear-gradient(180deg, rgba(20, 46, 32, 0.62), rgba(9, 13, 10, 0.96)); +} + +.nam-rack-input-diagnostics[data-tone="warning"] { + border-color: rgba(245, 200, 106, 0.34); + background: linear-gradient(180deg, rgba(68, 50, 18, 0.58), rgba(12, 10, 7, 0.96)); +} + +.nam-rack-input-diagnostics[data-tone="error"] { + border-color: rgba(239, 68, 68, 0.38); + background: linear-gradient(180deg, rgba(72, 18, 18, 0.52), rgba(12, 7, 7, 0.96)); +} + +.nam-rack-input-summary, +.nam-rack-input-copy { + min-width: 0; + display: grid; + gap: 2px; +} + +.nam-rack-input-summary span, +.nam-rack-input-copy span { + color: rgba(226, 232, 240, 0.58); + font-size: 10px; +} + +.nam-rack-input-summary strong { + overflow: hidden; + color: #f8fafc; + font-size: 12px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-rack-input-summary small { + color: rgba(226, 232, 240, 0.68); + font-size: 11px; +} + +.nam-rack-input-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} + +.nam-rack-input-actions button { + min-height: 27px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: #f8fafc; + font-size: 11px; + font-weight: 800; +} + +.nam-rack-input-actions button:hover { + border-color: rgba(142, 245, 194, 0.28); + background: rgba(142, 245, 194, 0.1); +} + +.nam-rack-auth-pill { + justify-self: end; + padding: 4px 7px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + color: rgba(226, 232, 240, 0.66); + font-size: 10px; + font-weight: 850; + white-space: nowrap; +} + +.nam-rack-auth-pill[data-ready="true"] { + border-color: rgba(74, 222, 128, 0.26); + color: #8ef5c2; + background: rgba(34, 197, 94, 0.1); +} + +.nam-focus-strip { + display: grid; + grid-template-columns: minmax(180px, 0.56fr) auto minmax(320px, 1.2fr); + gap: 10px; + align-items: center; + padding: 10px; + border: 1px solid rgba(245, 200, 106, 0.16); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(245, 200, 106, 0.1), rgba(69, 179, 107, 0.05)), + rgba(0, 0, 0, 0.26); +} + +.nam-focus-copy { + min-width: 0; + display: grid; + gap: 2px; +} + +.nam-focus-copy span { + color: #8ef5c2; + font-size: 10px; + font-weight: 900; + letter-spacing: 0; + text-transform: uppercase; +} + +.nam-focus-copy strong { + min-width: 0; + overflow: hidden; + color: #f8fafc; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-focus-copy small { + color: rgba(226, 232, 240, 0.58); + font-size: 11px; +} + +.nam-focus-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.nam-focus-actions button { + min-height: 29px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: #f8fafc; + font-size: 11px; + font-weight: 850; +} + +.nam-focus-actions button:hover { + border-color: rgba(245, 200, 106, 0.35); + background: rgba(245, 200, 106, 0.08); +} + +.nam-focus-controls { + min-width: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); + gap: 8px; +} + +.nam-rig-stage { + display: grid; + grid-template-columns: minmax(150px, 0.72fr) minmax(330px, 1.7fr) minmax(150px, 0.78fr); + gap: 10px; + padding: 10px; +} + +.nam-rack-controls { + display: grid; + gap: 8px; +} + +.nam-rack-control-band { + display: grid; + gap: 9px; + padding: 10px; +} + +.nam-rack-section-title { + color: rgba(226, 232, 240, 0.66); +} + +.nam-rack-control { + min-width: 0; +} + +.nam-rack-control-knob { + display: grid; + justify-items: center; + gap: 4px; + color: #f8fafc; + font-size: 11px; +} + +.nam-rack-control-knob input[type="range"] { + width: 100%; + height: 4px; + accent-color: #f5c86a; +} + +.nam-rack-knob-cap { + position: relative; + width: 48px; + height: 48px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 50%; + background: + radial-gradient(circle at 50% 50%, #181818 0 46%, transparent 48%), + conic-gradient(#f5c86a var(--nam-knob-pct), #2c2c2c 0); + box-shadow: + inset 0 0 0 3px #080808, + 0 5px 12px rgba(0, 0, 0, 0.34); +} + +.nam-rack-control-knob[data-size="large"] .nam-rack-knob-cap { + width: 58px; + height: 58px; +} + +.nam-rack-knob-cap > span { + position: absolute; + inset: 7px; + border-radius: 50%; + transform: rotate(var(--nam-knob-rotation)); +} + +.nam-rack-knob-cap > span::before { + content: ""; + position: absolute; + top: 1px; + left: 50%; + width: 3px; + height: 11px; + border-radius: 999px; + background: #f8fafc; + transform: translateX(-50%); +} + +.nam-rack-knob-label, +.nam-rack-control strong { + max-width: 100%; + overflow: hidden; + color: rgba(248, 250, 252, 0.88); + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-rack-control strong { + color: rgba(226, 232, 240, 0.58); + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.nam-rack-control-select, +.nam-rack-control-switch { + display: grid; + gap: 6px; + color: rgba(248, 250, 252, 0.84); + font-size: 11px; +} + +.nam-rack-control-switch { + min-height: 64px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 7px; + background: rgba(255, 255, 255, 0.04); +} + +.nam-rack-control-switch[data-active="true"] { + border-color: rgba(69, 179, 107, 0.5); + color: #8ef5c2; +} + +.nam-browser-drawer { + display: grid; + gap: 10px; + padding: 10px; +} + +.nam-browser-title { + justify-content: space-between; +} + +.nam-explorer { + display: grid; + grid-template-columns: minmax(190px, 0.72fr) minmax(320px, 1.35fr) minmax(230px, 0.9fr); + gap: 10px; +} + +.nam-explorer-sidebar, +.nam-explorer-main, +.nam-detail { + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(0, 0, 0, 0.22); +} + +.nam-explorer-sidebar, +.nam-explorer-main, +.nam-detail { + display: grid; + align-content: start; + gap: 10px; + padding: 10px; +} + +.nam-rack-slots { + display: grid; + grid-template-columns: 1fr; + gap: 6px; +} + +.nam-rack-slots > div { + min-width: 0; + padding: 8px; + border: 1px solid rgba(236, 196, 110, 0.18); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); +} + +.nam-shelves { + display: grid; + gap: 5px; +} + +.nam-shelves button { + min-height: 28px; + padding: 5px 8px; + border-radius: 6px; + font-size: 11px; + font-weight: 750; + text-align: left; +} + +.nam-shelves button:hover, +.nam-tabs button[data-active="true"], +.nam-favorite[data-active="true"] { + border-color: rgba(69, 179, 107, 0.44); + background: rgba(69, 179, 107, 0.12); + color: #8ef5c2; +} + +.nam-auth { + display: grid; + gap: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.nam-auth-head { + justify-content: space-between; +} + +.nam-auth-head > span, +.nam-auth-head > div { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.nam-auth-ready { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 4px 7px; + border: 1px solid rgba(142, 245, 194, 0.22); + border-radius: 999px; + color: #8ef5c2; + background: rgba(69, 179, 107, 0.1); + font-size: 11px; + font-weight: 850; +} + +.nam-auth-copy { + margin: 0; + line-height: 1.35; +} + +.nam-auth-library { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-auth-advanced { + display: grid; + gap: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + padding: 7px; + background: rgba(0, 0, 0, 0.18); +} + +.nam-auth-advanced summary { + cursor: pointer; + color: rgba(248, 250, 252, 0.78); + font-size: 11px; + font-weight: 750; +} + +.nam-auth-advanced[open] summary { + margin-bottom: 7px; +} + +.nam-auth-grid { + display: grid; + grid-template-columns: 1fr; + gap: 6px; +} + +.nam-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + justify-content: stretch; + align-items: flex-start; + min-width: 0; +} + +.nam-tabs { + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + gap: 4px; + min-width: 0; +} + +.nam-view-actions { + flex: 0 1 auto; + flex-wrap: wrap; + justify-content: flex-end; + min-width: 0; +} + +.nam-product .nam-save-tone-button { + border-color: rgba(142, 245, 194, 0.44); + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.3), rgba(7, 10, 8, 0.96)), + #101211; + color: #d1fae5; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 0 0 1px rgba(142, 245, 194, 0.1), + 0 10px 22px rgba(0, 0, 0, 0.22); + font-weight: 900; +} + +.nam-product .nam-save-tone-button:hover:not(:disabled) { + border-color: rgba(142, 245, 194, 0.62); + filter: brightness(1.04); + transform: translateY(-1px); +} + +.nam-product .nam-save-tone-button:disabled { + border-color: rgba(255, 255, 255, 0.09); + background: rgba(255, 255, 255, 0.07); + color: rgba(226, 232, 240, 0.48); + box-shadow: none; +} + +.nam-audition-strip { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid rgba(245, 200, 106, 0.26); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(245, 200, 106, 0.18), rgba(69, 179, 107, 0.08)), + linear-gradient(180deg, rgba(32, 31, 27, 0.98), rgba(9, 9, 8, 0.98)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.055), + 0 14px 28px rgba(0, 0, 0, 0.22); +} + +.nam-audition-strip[data-saved="true"] { + border-color: rgba(142, 245, 194, 0.3); + background: + linear-gradient(90deg, rgba(69, 179, 107, 0.16), rgba(245, 200, 106, 0.08)), + linear-gradient(180deg, rgba(24, 31, 26, 0.98), rgba(8, 10, 9, 0.98)); +} + +.nam-audition-strip > div:first-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.nam-audition-strip span { + color: #8ef5c2; + font-size: 10px; + font-weight: 900; + text-transform: uppercase; +} + +.nam-audition-strip strong { + min-width: 0; + overflow: hidden; + color: #f8fafc; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-audition-strip small { + min-width: 0; + overflow: hidden; + color: rgba(226, 232, 240, 0.62); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-audition-strip > div:last-child { + display: flex; + align-items: center; + gap: 6px; +} + +.nam-tabs button, +.nam-favorite { + border-radius: 6px; +} + +.nam-tabs button { + min-height: 28px; + padding: 5px 8px; + font-size: 11px; + font-weight: 750; + white-space: nowrap; +} + +.nam-filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(126px, 1fr)); + gap: 6px; +} + +.nam-filters label { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + grid-column: span 2; +} + +.nam-filters label > span { + min-width: 0; +} + +.nam-search-row { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(118px, auto) auto; + align-items: center; + gap: 8px; + min-width: 0; +} + +.nam-search-box { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 0 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(0, 0, 0, 0.18); +} + +.nam-search-box input { + border: 0; + background: transparent; +} + +.nam-slot-select { + min-width: 118px; +} + +.nam-status { + padding: 7px 9px; + border: 1px solid rgba(245, 200, 106, 0.24); + border-radius: 6px; + background: rgba(245, 200, 106, 0.08); +} + +.nam-live-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border: 1px solid rgba(69, 179, 107, 0.2); + border-radius: 6px; + background: rgba(69, 179, 107, 0.07); + color: rgba(226, 232, 240, 0.74); + font-size: 11px; +} + +.nam-live-pager > div { + display: flex; + gap: 4px; +} + +.nam-results { + display: grid; + max-height: 430px; + gap: 8px; + overflow: auto; +} + +.nam-results[data-view="cards"] { + grid-template-columns: repeat(auto-fit, minmax(184px, 1fr)); +} + +.nam-results[data-view="list"] { + grid-template-columns: 1fr; +} + +.nam-result-card { + position: relative; + min-width: 0; + display: grid; + gap: 8px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.02)); + cursor: pointer; +} + +.nam-result-select-target { + position: absolute; + inset: 0; + z-index: 8; + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: inherit; + background: transparent; + pointer-events: none; +} + +.nam-result-select-target:focus-visible { + outline: 2px solid rgba(245, 200, 106, 0.82); + outline-offset: -3px; +} + +.nam-result-card[data-selected="true"] { + border-color: rgba(245, 200, 106, 0.42); + box-shadow: inset 0 0 0 1px rgba(245, 200, 106, 0.12); +} + +.nam-result-card[data-update="true"] { + border-color: rgba(56, 189, 248, 0.28); + background: + linear-gradient(180deg, rgba(56, 189, 248, 0.07), rgba(255, 255, 255, 0.02)), + rgba(255, 255, 255, 0.02); +} + +.nam-result-card[data-view="list"] { + grid-template-columns: 76px minmax(0, 1fr) auto auto; + align-items: center; +} + +.nam-card-art { + min-height: 86px; + display: grid; + align-content: space-between; + padding: 9px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 7px; + background: + linear-gradient(145deg, rgba(245, 200, 106, 0.16), rgba(70, 82, 92, 0.16)), + #111; +} + +.nam-card-art[data-arch="a2"] { + background: + linear-gradient(145deg, rgba(69, 179, 107, 0.18), rgba(245, 200, 106, 0.12)), + #111; +} + +.nam-card-art span { + width: fit-content; + padding: 2px 6px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.38); + color: #8ef5c2; + font-size: 10px; + font-weight: 900; +} + +.nam-card-art strong { + color: #f8fafc; + font-size: 12px; +} + +.nam-result-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.nam-result-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 5px; +} + +.nam-favorite { + position: absolute; + top: 8px; + right: 8px; + width: 27px; + height: 27px; + display: grid; + place-items: center; + padding: 0; +} + +.nam-stats { + flex-wrap: wrap; + gap: 5px; +} + +.nam-stats span { + padding: 2px 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: rgba(226, 232, 240, 0.66); + font-size: 10px; +} + +.nam-detail { + position: sticky; + top: 0; +} + +.nam-detail-art { + min-height: 118px; + display: grid; + align-content: space-between; + padding: 12px; + border: 1px solid rgba(245, 200, 106, 0.24); + border-radius: 8px; + background: + linear-gradient(145deg, rgba(245, 200, 106, 0.18), rgba(69, 179, 107, 0.09)), + #111; +} + +.nam-detail-art span, +.nam-detail-art strong { + color: #f8fafc; + font-weight: 900; +} + +.nam-detail h4 { + margin: 0; + color: #f8fafc; + font-size: 15px; + line-height: 1.25; +} + +.nam-detail p { + margin: 0; + color: rgba(226, 232, 240, 0.66); + font-size: 12px; + line-height: 1.45; +} + +.nam-detail dl { + display: grid; + gap: 6px; + margin: 0; +} + +.nam-detail dl > div { + min-width: 0; + display: grid; + grid-template-columns: 78px minmax(0, 1fr); + gap: 8px; +} + +.nam-detail dt { + color: rgba(226, 232, 240, 0.52); + font-size: 11px; +} + +.nam-detail dd { + min-width: 0; + margin: 0; + overflow: hidden; + color: rgba(248, 250, 252, 0.88); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-detail dd a { + color: #8ef5c2; + text-decoration: none; +} + +.nam-detail dd a:hover { + text-decoration: underline; +} + +.nam-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.nam-source-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: #8ef5c2; + font-size: 12px; + text-decoration: none; +} + +.nam-empty { + padding: 14px; + border: 1px dashed rgba(255, 255, 255, 0.14); + border-radius: 7px; + color: rgba(255, 255, 255, 0.55); + font-size: 12px; + text-align: center; +} + +.nam-empty-state { + min-height: 250px; + grid-column: 1 / -1; + display: grid; + align-content: center; + justify-items: center; + gap: 9px; + padding: 26px; + border: 1px dashed rgba(236, 196, 110, 0.2); + border-radius: 8px; + background: + radial-gradient(circle at 50% 0%, rgba(245, 200, 106, 0.1), transparent 34%), + linear-gradient(180deg, rgba(30, 31, 29, 0.72), rgba(10, 10, 9, 0.9)); + color: rgba(226, 232, 240, 0.7); + text-align: center; +} + +.nam-empty-state > span { + display: grid; + width: 48px; + height: 48px; + place-items: center; + border: 1px solid rgba(245, 200, 106, 0.24); + border-radius: 50%; + color: #f5c86a; + background: rgba(0, 0, 0, 0.2); +} + +.nam-empty-state strong { + color: #f8fafc; + font-size: 15px; +} + +.nam-empty-state p { + max-width: 360px; + margin: 0; + font-size: 12px; + line-height: 1.45; +} + +.builtin-plugin-panel[data-kind="nam"] { + min-height: 0; + overflow: auto; +} + +.builtin-plugin-panel[data-kind="nam"] .builtin-panel-content { + min-height: 0; +} + +.nam-product { + min-height: 0; +} + +.nam-browser-drawer { + min-height: 0; + overflow: visible; +} + +.nam-explorer { + grid-template-columns: minmax(220px, 0.72fr) minmax(520px, 1.45fr) minmax(300px, 0.95fr); + gap: 12px; + min-height: 0; + padding: 2px; +} + +.nam-explorer-sidebar, +.nam-explorer-main, +.nam-detail { + border-color: rgba(236, 196, 110, 0.12); + background: + linear-gradient(180deg, rgba(30, 31, 29, 0.96), rgba(12, 13, 12, 0.98)), + #10100f; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.045), + 0 16px 34px rgba(0, 0, 0, 0.24); +} + +.nam-explorer-main { + min-height: 0; +} + +.nam-rack-slots > div { + display: grid; + gap: 4px; + background: + linear-gradient(180deg, rgba(236, 196, 110, 0.08), rgba(255, 255, 255, 0.025)), + #141412; +} + +.nam-rack-slots span { + color: rgba(226, 232, 240, 0.56); + font-size: 10px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} + +.nam-rack-slots strong { + min-width: 0; + overflow: hidden; + color: #f8fafc; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-shelves button, +.nam-tabs button { + color: rgba(226, 232, 240, 0.72); + background: rgba(255, 255, 255, 0.035); +} + +.nam-shelves button:hover, +.nam-tabs button:hover, +.nam-tabs button[data-active="true"], +.nam-favorite[data-active="true"] { + border-color: rgba(142, 245, 194, 0.44); + background: rgba(69, 179, 107, 0.13); + color: #a7f3d0; +} + +.nam-toolbar { + gap: 10px; + padding-bottom: 2px; +} + +.nam-view-actions { + gap: 6px; +} + +.nam-view-actions > button, +.nam-detail-actions > button, +.nam-result-actions > button { + min-height: 30px; + white-space: nowrap; +} + +.nam-view-actions > button:first-child, +.nam-detail-actions > button:first-child { + box-shadow: 0 0 0 1px rgba(245, 200, 106, 0.2), 0 12px 22px rgba(0, 0, 0, 0.25); +} + +.nam-filters { + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.065); + border-radius: 8px; + background: rgba(0, 0, 0, 0.16); +} + +.nam-filters[data-open="false"] { + display: none; +} + +.nam-filters[data-open="true"] { + display: grid; +} + +.nam-library-summary { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 0.62fr) minmax(180px, 0.68fr) minmax(0, 1fr); + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid rgba(236, 196, 110, 0.1); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(245, 200, 106, 0.07), rgba(69, 179, 107, 0.045)), + rgba(0, 0, 0, 0.18); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045); +} + +.nam-library-summary > div:first-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.nam-library-summary strong { + color: #f8fafc; + font-size: 12px; + font-weight: 900; +} + +.nam-library-summary span { + min-width: 0; + overflow: hidden; + color: rgba(226, 232, 240, 0.62); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-catalog-health { + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 5px; +} + +.nam-catalog-health span { + max-width: 150px; + padding: 3px 7px; + border: 1px solid rgba(142, 245, 194, 0.13); + border-radius: 999px; + color: rgba(226, 232, 240, 0.72); + background: rgba(0, 0, 0, 0.2); + font-size: 9px; + font-weight: 850; +} + +.nam-catalog-health[data-stale="true"] span { + border-color: rgba(245, 200, 106, 0.22); + color: #f5c86a; + background: rgba(245, 200, 106, 0.08); +} + +.nam-active-filters { + min-width: 0; + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 5px; +} + +.nam-filter-chip { + max-width: 142px; + overflow: hidden; + padding: 4px 7px; + border: 1px solid rgba(142, 245, 194, 0.16); + border-radius: 999px; + color: rgba(226, 232, 240, 0.82); + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.1), rgba(0, 0, 0, 0.16)), + rgba(0, 0, 0, 0.2); + font-size: 10px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-filter-chip[data-muted="true"] { + border-color: rgba(255, 255, 255, 0.08); + color: rgba(226, 232, 240, 0.52); + background: rgba(255, 255, 255, 0.035); +} + +.nam-active-filters button { + min-height: 22px; + padding: 0 7px; + border: 1px solid rgba(245, 200, 106, 0.18); + border-radius: 999px; + color: #f5c86a; + background: rgba(245, 200, 106, 0.06); + font-size: 10px; + font-weight: 900; + cursor: pointer; +} + +.nam-search-row { + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.065); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(0, 0, 0, 0.14)), + #121211; +} + +.nam-search-box { + min-height: 34px; + color: rgba(226, 232, 240, 0.58); +} + +.nam-search-box:focus-within { + border-color: rgba(245, 200, 106, 0.36); + box-shadow: 0 0 0 1px rgba(245, 200, 106, 0.08); +} + +.nam-status { + border-color: rgba(142, 245, 194, 0.22); + background: rgba(69, 179, 107, 0.08); + color: rgba(226, 232, 240, 0.82); +} + +.nam-feedback { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px 11px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 8px; + color: rgba(226, 232, 240, 0.86); + background: + radial-gradient(circle at 2% 18%, rgba(245, 200, 106, 0.09), transparent 30%), + linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(0, 0, 0, 0.12)), + rgba(15, 23, 42, 0.34); + font-size: 11px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.nam-feedback[data-tone="success"] { + border-color: rgba(142, 245, 194, 0.26); + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.12), rgba(0, 0, 0, 0.12)), + rgba(5, 46, 22, 0.22); +} + +.nam-feedback[data-tone="warning"] { + border-color: rgba(245, 200, 106, 0.3); + background: + linear-gradient(180deg, rgba(245, 200, 106, 0.12), rgba(0, 0, 0, 0.12)), + rgba(69, 51, 13, 0.22); +} + +.nam-feedback[data-tone="error"] { + border-color: rgba(248, 113, 113, 0.34); + background: + linear-gradient(180deg, rgba(239, 68, 68, 0.12), rgba(0, 0, 0, 0.14)), + rgba(69, 10, 10, 0.24); +} + +.nam-feedback[data-tone="busy"] { + border-color: rgba(96, 165, 250, 0.28); + background: + linear-gradient(180deg, rgba(59, 130, 246, 0.12), rgba(0, 0, 0, 0.12)), + rgba(30, 41, 59, 0.24); +} + +.nam-feedback[data-tone="busy"] .nam-feedback-icon { + animation: nam-spin 900ms linear infinite; +} + +.nam-feedback-icon { + display: grid; + width: 26px; + height: 26px; + place-items: center; + border: 1px solid rgba(245, 200, 106, 0.18); + border-radius: 999px; + color: #f5c86a; + background: rgba(0, 0, 0, 0.22); +} + +.nam-feedback-copy { + min-width: 0; + display: grid; + gap: 2px; + overflow: hidden; +} + +.nam-feedback-copy strong, +.nam-feedback-copy small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-feedback-copy strong { + color: #f8fafc; + font-size: 12px; + font-weight: 900; +} + +.nam-feedback-copy small { + color: rgba(226, 232, 240, 0.64); + font-size: 11px; +} + +.nam-feedback > button { + min-height: 29px; + white-space: nowrap; +} + +@keyframes nam-spin { + to { + transform: rotate(360deg); + } +} + +.nam-results { + max-height: min(58vh, 620px); + padding-right: 2px; +} + +.nam-results[data-view="cards"] { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.nam-result-card { + overflow: hidden; + gap: 9px; + padding: 9px; + border-color: rgba(255, 255, 255, 0.075); + background: + linear-gradient(180deg, rgba(37, 38, 35, 0.96), rgba(18, 18, 17, 0.98)), + #151515; + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; +} + +.nam-result-card:hover { + transform: translateY(-1px); + border-color: rgba(236, 196, 110, 0.28); + box-shadow: 0 18px 34px rgba(0, 0, 0, 0.22); +} + +.nam-result-card:focus-visible, +.nam-shelves button:focus-visible, +.nam-tabs button:focus-visible, +.nam-view-tabs button:focus-visible, +.nam-chain-module-main:focus-visible, +.nam-chain-power:focus-visible, +.nam-chain-slot-actions button:focus-visible, +.nam-chain-grip:focus-visible { + outline: 2px solid rgba(245, 200, 106, 0.72); + outline-offset: 2px; +} + +.nam-result-card[data-selected="true"], +.nam-result-card[data-audition="true"] { + border-color: rgba(245, 200, 106, 0.68); + box-shadow: + inset 0 0 0 1px rgba(245, 200, 106, 0.18), + 0 0 0 1px rgba(245, 200, 106, 0.08), + 0 18px 36px rgba(0, 0, 0, 0.28); +} + +.nam-result-card[data-audition="true"]::before { + content: "Auditioning"; + position: absolute; + z-index: 2; + top: 9px; + left: 9px; + padding: 3px 7px; + border: 1px solid rgba(245, 200, 106, 0.3); + border-radius: 999px; + color: #fde68a; + background: rgba(0, 0, 0, 0.64); + font-size: 10px; + font-weight: 900; +} + +.nam-result-card[data-view="list"] { + grid-template-columns: 112px auto minmax(0, 1fr) auto auto; +} + +.nam-result-skeleton { + pointer-events: none; +} + +.nam-result-skeleton:hover { + transform: none; + box-shadow: none; +} + +.nam-result-skeleton .nam-card-art, +.nam-result-skeleton .nam-result-copy strong, +.nam-result-skeleton .nam-result-copy span, +.nam-result-skeleton .nam-result-copy small, +.nam-result-skeleton .nam-stats span, +.nam-result-skeleton .nam-result-actions i { + display: block; + min-height: 12px; + overflow: hidden; + border: 0; + border-radius: 999px; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.05), rgba(245, 200, 106, 0.12), rgba(255, 255, 255, 0.05)), + rgba(255, 255, 255, 0.04); + background-size: 220% 100%; + color: transparent; + animation: nam-skeleton 1400ms ease-in-out infinite; +} + +.nam-result-skeleton .nam-card-art { + min-height: 132px; + border-radius: 8px; +} + +.nam-result-skeleton .nam-card-art::before, +.nam-result-skeleton .nam-card-art::after { + display: none; +} + +.nam-result-skeleton .nam-result-copy strong { + width: 76%; +} + +.nam-result-skeleton .nam-result-copy span { + width: 62%; +} + +.nam-result-skeleton .nam-result-copy small { + width: 44%; +} + +.nam-result-skeleton .nam-stats span { + width: 54px; +} + +.nam-result-skeleton .nam-result-actions i { + width: 86px; + min-height: 30px; + border-radius: 6px; +} + +.nam-detail-skeleton { + display: grid; + gap: 10px; +} + +.nam-detail-skeleton div, +.nam-detail-skeleton strong, +.nam-detail-skeleton p, +.nam-detail-skeleton li { + display: block; + overflow: hidden; + border-radius: 999px; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.05), rgba(245, 200, 106, 0.12), rgba(255, 255, 255, 0.05)), + rgba(255, 255, 255, 0.04); + background-size: 220% 100%; + color: transparent; + animation: nam-skeleton 1400ms ease-in-out infinite; +} + +.nam-detail-skeleton div { + min-height: 174px; + border-radius: 8px; +} + +.nam-detail-skeleton strong { + width: 80%; + min-height: 20px; +} + +.nam-detail-skeleton p { + width: 100%; + min-height: 12px; + margin: 0; +} + +.nam-detail-skeleton p:nth-of-type(2) { + width: 68%; +} + +.nam-detail-skeleton ul { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.nam-detail-skeleton li { + min-height: 12px; +} + +@keyframes nam-skeleton { + 0% { + background-position: 120% 0; + } + + 100% { + background-position: -120% 0; + } +} + +.nam-card-art { + position: relative; + min-height: 132px; + aspect-ratio: 16 / 10; + align-content: end; + gap: 6px; + padding: 10px; + overflow: hidden; + background: + radial-gradient(circle at 72% 22%, rgba(245, 200, 106, 0.2), transparent 34%), + linear-gradient(145deg, rgba(245, 200, 106, 0.16), rgba(70, 82, 92, 0.18)), + #111; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + box-shadow: inset 0 -52px 46px rgba(0, 0, 0, 0.55); +} + +.nam-card-art::before, +.nam-detail-art::before { + content: ""; + position: absolute; + inset: 18px 18px 38px; + border: 1px solid rgba(236, 196, 110, 0.2); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(236, 196, 110, 0.2) 0 8%, transparent 8% 14%, rgba(236, 196, 110, 0.2) 14% 22%, transparent 22% 100%), + linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.2)), + #24221d; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.04), + 0 18px 34px rgba(0, 0, 0, 0.28); +} + +.nam-card-art::after, +.nam-detail-art::after { + content: ""; + position: absolute; + right: 28px; + bottom: 50px; + width: 82px; + height: 16px; + border-radius: 999px; + background: + radial-gradient(circle at 12px 8px, #f6d47e 0 3px, transparent 4px), + radial-gradient(circle at 32px 8px, #f6d47e 0 3px, transparent 4px), + radial-gradient(circle at 52px 8px, #f6d47e 0 3px, transparent 4px), + radial-gradient(circle at 72px 8px, #f6d47e 0 3px, transparent 4px), + rgba(0, 0, 0, 0.42); + box-shadow: 0 0 18px rgba(245, 200, 106, 0.12); +} + +.nam-result-card[data-has-art="true"] .nam-card-art, +.nam-detail-art[data-has-art="true"] { + background-color: #101010; + background-position: center; + background-repeat: no-repeat; + background-size: cover; +} + +.nam-result-card[data-has-art="true"] .nam-card-art::before, +.nam-result-card[data-has-art="true"] .nam-card-art::after, +.nam-detail-art[data-has-art="true"]::before, +.nam-detail-art[data-has-art="true"]::after { + display: none; +} + +.nam-result-card[data-view="list"] .nam-card-art { + min-height: 76px; + aspect-ratio: 4 / 3; +} + +.nam-card-art span, +.nam-detail-art span, +.nam-card-art > svg, +.nam-detail-art > svg { + position: relative; + z-index: 1; +} + +.nam-card-art span, +.nam-detail-art span { + border: 1px solid rgba(142, 245, 194, 0.22); +} + +.nam-card-art strong { + position: relative; + z-index: 1; + width: fit-content; + padding: 3px 7px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.52); + font-size: 11px; +} + +.nam-card-audition-puck { + position: relative; + z-index: 1; + display: inline-flex; + width: fit-content; + max-width: 100%; + align-items: center; + gap: 6px; + padding: 5px 8px; + border: 1px solid rgba(245, 200, 106, 0.2); + border-radius: 999px; + color: rgba(248, 250, 252, 0.84); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.065), rgba(0, 0, 0, 0.18)), + rgba(0, 0, 0, 0.3); + font-size: 11px; + font-style: normal; + font-weight: 850; + white-space: nowrap; +} + +.nam-result-card:hover .nam-card-audition-puck { + border-color: rgba(245, 200, 106, 0.42); + color: #f5c86a; +} + +.nam-result-card[data-audition="true"] .nam-card-audition-puck { + border-color: rgba(142, 245, 194, 0.36); + color: #8ef5c2; + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.18), rgba(0, 0, 0, 0.22)), + rgba(0, 0, 0, 0.36); +} + +.nam-result-card[data-busy="true"] .nam-card-audition-puck svg { + animation: nam-spin 900ms linear infinite; +} + +.nam-result-copy strong { + display: -webkit-box; + overflow: hidden; + color: #f8fafc; + font-size: 13px; + line-height: 1.25; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.nam-result-copy span, +.nam-result-copy small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-creator-line { + display: flex; + align-items: center; + gap: 6px; +} + +.nam-creator-line img { + width: 18px; + height: 18px; + flex: 0 0 auto; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 50%; + object-fit: cover; +} + +.nam-stats { + align-items: center; + justify-content: flex-start; +} + +.nam-stats span { + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(0, 0, 0, 0.22); +} + +.nam-favorite { + z-index: 3; + background: rgba(0, 0, 0, 0.42); + backdrop-filter: blur(8px); +} + +.nam-detail { + max-height: min(72vh, 760px); + overflow: auto; +} + +.nam-detail-art { + position: relative; + min-height: 174px; + align-content: end; + gap: 8px; + overflow: hidden; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + box-shadow: inset 0 -72px 58px rgba(0, 0, 0, 0.58); +} + +.nam-detail-art strong { + position: relative; + z-index: 1; + width: fit-content; + padding: 4px 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.54); + font-size: 13px; +} + +.nam-detail h4 { + font-size: 18px; +} + +.nam-detail-actions { + padding-top: 2px; +} + +.plugin-editor-window-app .builtin-plugin-panel[data-kind="nam"] { + height: 100%; + overflow: hidden; +} + +.plugin-editor-window-app .builtin-plugin-panel[data-kind="nam"] .builtin-panel-header { + position: relative; + z-index: 10; +} + +.plugin-editor-window-app .nam-product { + height: calc(100vh - 42px); + min-height: 0; + overflow: hidden; +} + +.builtin-plugin-panel[data-kind="nam"] { + min-height: 0; + overflow: hidden; +} + +.nam-product { + grid-template-rows: auto auto minmax(0, 1fr); + min-height: min(820px, calc(100vh - 120px)); + overflow: hidden; +} + +.nam-product:has(.nam-slot-browser) { + grid-template-rows: auto auto minmax(118px, auto) minmax(0, 1fr); +} + +.nam-product-topbar { + grid-template-columns: + minmax(170px, 1.05fr) + minmax(190px, 1.25fr) + auto + auto + auto + minmax(92px, 0.52fr) + minmax(92px, 0.52fr) + minmax(92px, 0.52fr); + padding: 9px; +} + +.nam-view-tabs { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + background: rgba(0, 0, 0, 0.24); +} + +.nam-view-tabs button { + min-height: 28px; + padding: 0 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: rgba(226, 232, 240, 0.68); + font-size: 11px; + font-weight: 900; + cursor: pointer; +} + +.nam-view-tabs button[data-active="true"] { + border: 1px solid rgba(142, 245, 194, 0.34); + color: #d1fae5; + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.22), rgba(0, 0, 0, 0.18)), + #101211; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 0 0 1px rgba(142, 245, 194, 0.08); +} + +.nam-save-tone-topbar, +.nam-library-cta, +.nam-primary-stage-action { + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 12px; + border: 1px solid rgba(142, 245, 194, 0.34); + border-radius: 7px; + color: #d1fae5; + background: + radial-gradient(circle at 20% 0%, rgba(142, 245, 194, 0.18), transparent 38%), + linear-gradient(180deg, rgba(69, 179, 107, 0.2), rgba(7, 10, 8, 0.94)), + #101211; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 10px 22px rgba(0, 0, 0, 0.18); + font-size: 11px; + font-weight: 950; + white-space: nowrap; + cursor: pointer; +} + +.nam-save-tone-topbar { + border-color: rgba(245, 200, 106, 0.44); + color: #fff7d6; + background: + radial-gradient(circle at 18% 0%, rgba(245, 200, 106, 0.22), transparent 42%), + linear-gradient(180deg, rgba(156, 112, 36, 0.28), rgba(7, 7, 6, 0.94)), + #14100a; +} + +.nam-library-cta[data-active="true"], +.nam-save-tone-topbar:hover:not(:disabled), +.nam-library-cta:hover, +.nam-primary-stage-action:hover { + border-color: rgba(142, 245, 194, 0.58); + background: + radial-gradient(circle at 20% 0%, rgba(142, 245, 194, 0.26), transparent 38%), + linear-gradient(180deg, rgba(69, 179, 107, 0.3), rgba(7, 10, 8, 0.96)), + #101211; +} + +.nam-save-tone-topbar:hover:not(:disabled) { + border-color: rgba(245, 200, 106, 0.64); + background: + radial-gradient(circle at 18% 0%, rgba(245, 200, 106, 0.3), transparent 42%), + linear-gradient(180deg, rgba(156, 112, 36, 0.36), rgba(7, 7, 6, 0.96)), + #171108; +} + +.nam-save-tone-topbar:disabled { + cursor: wait; + opacity: 0.72; +} + +.nam-tuner-strip { + min-width: 0; + min-height: 34px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 0 9px; + border: 1px solid rgba(142, 245, 194, 0.18); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.08), rgba(0, 0, 0, 0.16)), + #101211; + color: #8ef5c2; +} + +.nam-tuner-strip span { + overflow: hidden; + color: rgba(226, 232, 240, 0.66); + font-size: 10px; + font-weight: 900; + text-transform: uppercase; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-tuner-strip strong { + color: #f8fafc; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.nam-product-main { + min-height: 0; + overflow: hidden; +} + +.nam-rack-stage-view { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + grid-template-columns: minmax(0, 1fr) minmax(190px, 0.24fr); + gap: 10px; +} + +.nam-stage-hero { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + gap: 10px; + padding: 12px; + border: 1px solid rgba(236, 196, 110, 0.15); + border-radius: 8px; + background: + radial-gradient(circle at 70% 18%, rgba(245, 200, 106, 0.12), transparent 34%), + linear-gradient(180deg, rgba(26, 26, 24, 0.98), rgba(8, 8, 7, 0.98)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.055); +} + +.nam-stage-hero-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; +} + +.nam-module-panel { + position: relative; + min-height: 0; + display: grid; + grid-template-columns: minmax(190px, 0.32fr) minmax(0, 0.24fr) minmax(0, 1fr); + align-items: center; + gap: 22px; + padding: clamp(18px, 2.2vw, 30px); + overflow: hidden; + border: 1px solid rgba(236, 196, 110, 0.18); + border-radius: 8px; + background: + linear-gradient(135deg, rgba(245, 200, 106, 0.13), transparent 28%), + linear-gradient(180deg, #242424, #0d0d0d); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + inset 0 -42px 68px rgba(0, 0, 0, 0.38); +} + +.nam-module-copy { + display: grid; + gap: 8px; + min-width: 0; +} + +.nam-module-copy span { + color: #8ef5c2; + font-size: 10px; + font-weight: 900; + text-transform: uppercase; +} + +.nam-module-copy strong { + color: #f8fafc; + font-size: clamp(20px, 2vw, 30px); + line-height: 1.1; +} + +.nam-module-copy small { + color: rgba(226, 232, 240, 0.62); + font-size: 12px; + line-height: 1.45; +} + +.nam-module-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.nam-module-chips span { + width: fit-content; + padding: 4px 8px; + border: 1px solid rgba(142, 245, 194, 0.18); + border-radius: 999px; + color: #8ef5c2; + background: rgba(69, 179, 107, 0.08); + font-size: 10px; + font-weight: 900; + text-transform: uppercase; +} + +.nam-stage-sidebar { + min-width: 0; + display: grid; + align-content: start; + gap: 10px; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 8px; + background: linear-gradient(180deg, rgba(30, 31, 29, 0.96), rgba(10, 10, 9, 0.98)); +} + +.nam-stage-sidebar > div { + display: grid; + gap: 4px; + padding: 10px; + border: 1px solid rgba(236, 196, 110, 0.14); + border-radius: 7px; + background: rgba(0, 0, 0, 0.18); +} + +.nam-stage-sidebar span { + color: rgba(226, 232, 240, 0.56); + font-size: 10px; + font-weight: 900; + text-transform: uppercase; +} + +.nam-stage-sidebar strong { + overflow: hidden; + color: #f8fafc; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-rack-utility-strip { + grid-column: 1 / -1; + min-width: 0; + display: grid; + grid-template-columns: repeat(6, minmax(80px, 1fr)) auto; + align-items: center; + gap: 6px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.085); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(28, 29, 28, 0.96), rgba(10, 10, 9, 0.98)), + #10100f; +} + +.nam-rack-utility-strip span, +.nam-rack-utility-strip button { + min-width: 0; + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 8px; + border: 1px solid rgba(255, 255, 255, 0.075); + border-radius: 6px; + color: rgba(226, 232, 240, 0.72); + background: rgba(0, 0, 0, 0.2); + font-size: 10px; + font-weight: 850; + white-space: nowrap; +} + +.nam-rack-utility-strip span { + overflow: hidden; + text-overflow: ellipsis; +} + +.nam-rack-utility-strip svg { + flex: 0 0 auto; + color: #f5c86a; +} + +.nam-rack-utility-strip button { + cursor: pointer; +} + +.nam-rack-utility-strip button:hover { + border-color: rgba(245, 200, 106, 0.32); + color: #f8fafc; + background: rgba(245, 200, 106, 0.08); +} + +.nam-browser-drawer-full, +.nam-advanced-view { + height: 100%; + min-height: 0; + overflow: hidden; +} + +.nam-browser-drawer-full { + grid-template-rows: auto minmax(0, 1fr); +} + +.nam-browser-drawer-full .nam-explorer { + height: 100%; + min-height: 0; + grid-template-columns: minmax(205px, 0.52fr) minmax(500px, 1.35fr) minmax(330px, 0.7fr); +} + +.nam-browser-drawer-full .nam-explorer-sidebar, +.nam-browser-drawer-full .nam-explorer-main, +.nam-browser-drawer-full .nam-detail { + min-height: 0; + overflow: auto; +} + +.nam-browser-drawer-full .nam-results { + max-height: none; + min-height: 0; +} + +.nam-browser-drawer-full .nam-results[data-view="cards"] { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.nam-browser-drawer-full .nam-result-card[data-view="cards"] { + min-height: 244px; +} + +.nam-browser-drawer-full .nam-card-art { + min-height: 142px; +} + +.nam-advanced-view { + display: grid; + align-content: start; + gap: 10px; + padding-right: 4px; + overflow: auto; +} + +.nam-advanced-header { + display: flex; + align-items: center; + gap: 9px; + padding: 11px 12px; + border: 1px solid rgba(245, 200, 106, 0.14); + border-radius: 8px; + background: rgba(0, 0, 0, 0.22); +} + +.nam-advanced-header strong { + display: block; + color: #f8fafc; + font-size: 13px; +} + +.nam-advanced-header small { + color: rgba(226, 232, 240, 0.58); + font-size: 11px; +} + +.nam-chain { + position: relative; + isolation: isolate; + background: + linear-gradient(90deg, transparent 0 4%, rgba(245, 200, 106, 0.16) 4% 96%, transparent 96%), + linear-gradient(180deg, rgba(22, 23, 21, 0.98), rgba(8, 8, 7, 0.98)); +} + +.nam-chain::before { + content: ""; + position: absolute; + z-index: 0; + left: 38px; + right: 38px; + top: 50%; + height: 2px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(245, 200, 106, 0.08), rgba(245, 200, 106, 0.34), rgba(245, 200, 106, 0.08)); + transform: translateY(-50%); +} + +.nam-chain-module { + z-index: 1; + background: + radial-gradient(circle at 16% 0%, rgba(255, 255, 255, 0.065), transparent 36%), + linear-gradient(180deg, rgba(32, 33, 31, 0.98), rgba(12, 12, 11, 0.98)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.07), + inset 0 -14px 24px rgba(0, 0, 0, 0.2); +} + +.nam-chain-module[data-active="true"] { + background: + radial-gradient(circle at 16% 0%, rgba(142, 245, 194, 0.16), transparent 38%), + linear-gradient(180deg, rgba(28, 43, 34, 0.98), rgba(10, 16, 13, 0.98)); +} + +.nam-chain-module[data-selected="true"] { + transform: translateY(-1px); + border-color: rgba(255, 216, 122, 0.92); + background: + radial-gradient(circle at 20% 0%, rgba(245, 200, 106, 0.24), transparent 42%), + linear-gradient(180deg, rgba(54, 43, 22, 0.98), rgba(13, 12, 9, 0.98)); + box-shadow: + inset 0 0 0 2px rgba(255, 216, 122, 0.5), + 0 12px 28px rgba(0, 0, 0, 0.26), + 0 0 34px rgba(245, 200, 106, 0.2); +} + +.nam-chain-icon { + border-radius: 8px; + background: + radial-gradient(circle at 50% 15%, rgba(255, 255, 255, 0.14), transparent 42%), + linear-gradient(180deg, rgba(245, 200, 106, 0.14), rgba(0, 0, 0, 0.18)); +} + +.nam-stage-hero { + position: relative; + isolation: isolate; + overflow: hidden; + background: + radial-gradient(circle at 72% 10%, rgba(245, 200, 106, 0.16), transparent 30%), + radial-gradient(circle at 22% 92%, rgba(142, 245, 194, 0.07), transparent 34%), + linear-gradient(180deg, rgba(24, 24, 22, 0.99), rgba(6, 6, 5, 0.99)); +} + +.nam-stage-hero::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.03) 0 1px, transparent 1px 18px), + linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 22%, rgba(0, 0, 0, 0.28)); + opacity: 0.46; +} + +.nam-module-panel { + background: + radial-gradient(circle at 18% 15%, rgba(245, 200, 106, 0.16), transparent 34%), + radial-gradient(circle at 86% 82%, rgba(142, 245, 194, 0.08), transparent 30%), + linear-gradient(180deg, #242522, #080807); +} + +.nam-product[data-view="rack"] .nam-module-panel { + grid-template-columns: minmax(250px, 0.34fr) minmax(180px, 0.24fr) minmax(0, 1fr); +} + +.nam-product[data-view="rack"] .nam-module-panel[data-module="gate"], +.nam-product[data-view="rack"] .nam-module-panel[data-module="eq"], +.nam-product[data-view="rack"] .nam-module-panel[data-module="mod"], +.nam-product[data-view="rack"] .nam-module-panel[data-module="delay"], +.nam-product[data-view="rack"] .nam-module-panel[data-module="reverb"] { + background: + radial-gradient(circle at 16% 18%, rgba(245, 200, 106, 0.16), transparent 30%), + radial-gradient(circle at 84% 80%, rgba(69, 179, 107, 0.1), transparent 34%), + linear-gradient(180deg, #20211f, #070707); +} + +.nam-product[data-view="rack"] .nam-module-copy { + align-content: center; + gap: 10px; +} + +.nam-product[data-view="rack"] .nam-module-copy > span { + color: #8ef5c2; + font-size: 10px; + font-weight: 900; + text-transform: uppercase; +} + +.nam-product[data-view="rack"] .nam-module-copy strong { + font-size: clamp(22px, 2.2vw, 32px); +} + +.nam-product[data-view="rack"] .nam-module-copy small { + max-width: 300px; +} + +.nam-product[data-view="rack"] .nam-module-chips span { + color: #8ef5c2; + letter-spacing: 0; +} + +.nam-browser-drawer-full .nam-explorer { + background: + radial-gradient(circle at 28% 0%, rgba(245, 200, 106, 0.06), transparent 34%), + linear-gradient(180deg, rgba(15, 15, 14, 0.98), rgba(7, 7, 6, 0.98)); +} + +.nam-result-card { + border-color: rgba(236, 196, 110, 0.1); + background: + linear-gradient(180deg, rgba(34, 35, 32, 0.98), rgba(12, 12, 11, 0.98)), + #151515; +} + +.nam-results[data-view="cards"] { + gap: 10px; +} + +.nam-browser-drawer-full .nam-results[data-view="cards"] { + grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); +} + +.nam-browser-drawer-full .nam-result-card[data-view="cards"] { + min-height: 286px; +} + +.nam-card-art { + min-height: 154px; + border-color: rgba(236, 196, 110, 0.14); + box-shadow: + inset 0 -64px 56px rgba(0, 0, 0, 0.62), + 0 14px 28px rgba(0, 0, 0, 0.2); +} + +.nam-result-card[data-view="list"] { + min-height: 104px; +} + +.nam-result-card[data-view="list"] .nam-card-art { + min-height: 86px; + aspect-ratio: 16 / 10; +} + +.nam-detail-art { + min-height: 218px; + border-color: rgba(236, 196, 110, 0.16); + box-shadow: + inset 0 -84px 70px rgba(0, 0, 0, 0.66), + 0 18px 36px rgba(0, 0, 0, 0.24); +} + +.nam-result-card[data-provider-art="true"] .nam-card-art::before, +.nam-result-card[data-provider-art="true"] .nam-card-art::after, +.nam-detail-art[data-provider-art="true"]::before, +.nam-detail-art[data-provider-art="true"]::after { + display: none; +} + +.nam-result-card[data-has-art="true"] .nam-card-art, +.nam-detail-art[data-has-art="true"], +.nam-browse-hero-art[data-has-art="true"] { + background-image: var(--nam-card-profile-image); +} + +.nam-result-card[data-provider-art="false"] .nam-card-art::before, +.nam-result-card[data-provider-art="false"] .nam-card-art::after, +.nam-detail-art[data-provider-art="false"]::before, +.nam-detail-art[data-provider-art="false"]::after { + display: none; +} + +.nam-result-card[data-provider-art="false"] .nam-card-art, +.nam-detail-art[data-provider-art="false"], +.nam-browse-hero-art[data-provider-art="false"] { + background-blend-mode: color, soft-light, normal; +} + +.nam-result-card[data-provider-art="false"][data-fallback-profile="clean"] .nam-card-art, +.nam-detail-art[data-provider-art="false"][data-fallback-profile="clean"], +.nam-browse-hero-art[data-provider-art="false"][data-fallback-profile="clean"] { + background-image: + linear-gradient(135deg, rgba(142, 245, 194, 0.22), transparent 46%), + radial-gradient(circle at 80% 18%, rgba(245, 200, 106, 0.13), transparent 34%), + var(--nam-card-profile-image, none); + border-color: rgba(142, 245, 194, 0.24); +} + +.nam-result-card[data-provider-art="false"][data-fallback-profile="crunch"] .nam-card-art, +.nam-detail-art[data-provider-art="false"][data-fallback-profile="crunch"], +.nam-browse-hero-art[data-provider-art="false"][data-fallback-profile="crunch"] { + background-image: + linear-gradient(135deg, rgba(245, 200, 106, 0.28), transparent 44%), + radial-gradient(circle at 78% 22%, rgba(248, 113, 113, 0.12), transparent 34%), + var(--nam-card-profile-image, none); + border-color: rgba(245, 200, 106, 0.28); +} + +.nam-result-card[data-provider-art="false"][data-fallback-profile="high-gain"] .nam-card-art, +.nam-detail-art[data-provider-art="false"][data-fallback-profile="high-gain"], +.nam-browse-hero-art[data-provider-art="false"][data-fallback-profile="high-gain"] { + background-image: + linear-gradient(135deg, rgba(248, 113, 113, 0.22), transparent 48%), + radial-gradient(circle at 76% 16%, rgba(196, 181, 253, 0.15), transparent 34%), + var(--nam-card-profile-image, none); + border-color: rgba(248, 113, 113, 0.26); +} + +.nam-result-card[data-provider-art="false"][data-fallback-profile="pedal"] .nam-card-art, +.nam-detail-art[data-provider-art="false"][data-fallback-profile="pedal"], +.nam-browse-hero-art[data-provider-art="false"][data-fallback-profile="pedal"] { + background-image: + radial-gradient(circle at 18% 12%, rgba(125, 211, 252, 0.2), transparent 34%), + linear-gradient(135deg, rgba(69, 179, 107, 0.16), transparent 52%), + var(--nam-card-profile-image, none); + border-color: rgba(125, 211, 252, 0.24); +} + +.nam-result-card[data-provider-art="false"][data-fallback-profile="cab"] .nam-card-art, +.nam-detail-art[data-provider-art="false"][data-fallback-profile="cab"], +.nam-browse-hero-art[data-provider-art="false"][data-fallback-profile="cab"] { + background-image: + linear-gradient(135deg, rgba(148, 163, 184, 0.2), transparent 48%), + radial-gradient(circle at 78% 20%, rgba(245, 200, 106, 0.1), transparent 34%), + var(--nam-card-profile-image, none); + border-color: rgba(148, 163, 184, 0.26); +} + +.nam-product[data-view="rack"] .nam-rack-stage-view { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto auto; + gap: 8px; +} + +.nam-product[data-view="rack"] .nam-stage-hero { + padding: 10px; +} + +.nam-product[data-view="rack"] .nam-stage-hero-head { + min-height: 44px; + padding: 0 2px; +} + +.nam-product[data-view="rack"] .nam-focus-copy { + display: grid; + gap: 2px; +} + +.nam-product[data-view="rack"] .nam-focus-copy strong { + font-size: clamp(16px, 1.45vw, 22px); + line-height: 1.05; +} + +.nam-product[data-view="rack"] .nam-focus-copy small { + max-width: 620px; +} + +.nam-product[data-view="rack"] .nam-stage-sidebar { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 6px; + background: + linear-gradient(180deg, rgba(22, 23, 21, 0.98), rgba(8, 8, 7, 0.98)), + #10100f; +} + +.nam-product[data-view="rack"] .nam-stage-sidebar > div { + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + column-gap: 8px; + padding: 7px 9px; + min-height: 34px; +} + +.nam-product[data-view="rack"] .nam-stage-sidebar span { + min-width: 58px; +} + +.nam-product[data-view="rack"] .nam-stage-sidebar strong { + text-align: right; +} + +.nam-product[data-view="rack"] .nam-rack-utility-strip { + padding: 6px; +} + +.nam-product[data-view="rack"] .nam-rack-utility-strip span, +.nam-product[data-view="rack"] .nam-rack-utility-strip button { + min-height: 25px; +} + +.nam-product-topbar { + grid-template-columns: + minmax(170px, 1.05fr) + minmax(190px, 1.25fr) + auto + auto + minmax(118px, 0.64fr) + minmax(118px, 0.64fr); +} + +.nam-mini-meter { + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + column-gap: 7px; + row-gap: 3px; +} + +.nam-mini-meter span { + color: rgba(226, 232, 240, 0.72); +} + +.nam-mini-meter strong { + color: rgba(226, 232, 240, 0.56); + font-size: 10px; + font-weight: 800; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.nam-mini-meter i { + height: 10px; +} + +.nam-mini-meter i::after { + transition: width 110ms linear; +} + +.nam-mini-meter i > b { + transition: left 150ms linear, opacity 120ms ease; +} + +.nam-mini-meter[data-active="false"] i::after { + opacity: 0.28; +} + +.nam-mini-meter[data-silent="true"] i::after { + opacity: 0; +} + +.nam-mini-meter[data-clip="true"] span { + color: #fb7185; +} + +.nam-rack-control-knob { + position: relative; + touch-action: none; + user-select: none; + cursor: ns-resize; +} + +.nam-rack-control-knob:focus-within .nam-rack-knob-cap { + outline: 2px solid rgba(142, 245, 194, 0.54); + outline-offset: 3px; +} + +.nam-rack-control-knob .nam-rack-knob-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.nam-rack-knob-cap { + background: + radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.26), transparent 17%), + radial-gradient(circle at 50% 54%, #1a1a1a 0 43%, #070707 44% 58%, transparent 60%), + conic-gradient( + from -135deg, + rgba(255, 217, 120, 0.1) 0 var(--nam-knob-fill-start), + #ffd978 var(--nam-knob-fill-start) var(--nam-knob-fill-end), + rgba(65, 65, 65, 0.92) var(--nam-knob-fill-end) 75%, + transparent 75% 100% + ); +} + +.nam-product[data-view="rack"] .nam-rack-stage-view[data-module="amp"] { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto auto; +} + +.nam-product[data-view="rack"] .nam-rack-stage-view[data-module="amp"] .nam-stage-hero { + min-height: 0; +} + +.nam-product[data-view="rack"] .nam-rack-stage-view[data-module="amp"] .nam-stage-sidebar { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 900px) { + .fx-chain-panel-two-column { + width: 98%; + height: 92vh; + } + + .fx-chain-two-column-content { + grid-template-columns: 1fr; + grid-template-rows: minmax(260px, 44vh) minmax(0, 1fr); + } + + .fx-chain-loaded-column { + border-right: 0; + border-bottom: 1px solid #404040; + } + + .builtin-param-grid, + .builtin-macro-strip { + grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); + } + + .nam-product-topbar, + .nam-rig-stage, + .nam-rack-stage-view, + .nam-browser-drawer-full .nam-explorer, + .nam-explorer { + grid-template-columns: 1fr; + } + + .nam-product-main { + overflow: auto; + } + + .nam-rack-stage-view, + .nam-browser-drawer-full .nam-explorer { + height: auto; + min-height: 100%; + } + + .nam-stage-hero { + min-height: 540px; + } + + .nam-stage-hero-head, + .nam-module-panel { + grid-template-columns: 1fr; + } + + .nam-stage-sidebar { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .nam-rack-utility-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .nam-browser-drawer-full { + overflow: auto; + } + + .nam-browser-drawer-full .nam-explorer-sidebar, + .nam-browser-drawer-full .nam-explorer-main, + .nam-browser-drawer-full .nam-detail { + overflow: visible; + } + + .nam-chain { + grid-template-columns: 1fr; + } + + .nam-chain-slots { + grid-template-columns: repeat(4, minmax(80px, 1fr)); + } + + .nam-auth-grid { + grid-template-columns: 1fr; + } +} + +@media (min-width: 901px) and (max-height: 840px) { + .nam-product { + gap: 8px; + padding: 8px; + min-height: calc(100vh - 78px); + } + + .nam-product-topbar { + padding: 7px; + } + + .nam-stage-hero { + gap: 8px; + padding: 10px; + } + +} + +@media (max-width: 520px) { + .builtin-plugin-panel { + padding: 8px; + } + + .builtin-visual { + height: 112px; + } + + .builtin-param-grid, + .builtin-macro-strip { + grid-template-columns: 1fr; + } + + .nam-product { + padding: 8px; + } + + .nam-chain { + grid-template-columns: 1fr; + } + + .nam-chain-slots { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .nam-filters, + .nam-search-row, + .nam-result-card[data-view="list"] { + grid-template-columns: 1fr; + } + + .nam-filters label { + grid-column: 1 / -1; + } + + .nam-results[data-view="cards"] { + grid-template-columns: 1fr; + } + + .nam-stage-sidebar { + grid-template-columns: 1fr; + } + + .nam-rack-utility-strip { + grid-template-columns: 1fr; + } + + .nam-stage-hero { + min-height: 620px; + } + + .nam-module-panel { + gap: 14px; + } + + .nam-detail dl > div { + grid-template-columns: 1fr; + gap: 2px; + } +} + +.nam-shelves button[data-active="true"] { + position: relative; + border-color: rgba(245, 200, 106, 0.4); + color: #f8fafc; + background: + linear-gradient(90deg, rgba(245, 200, 106, 0.16), rgba(69, 179, 107, 0.08)), + rgba(255, 255, 255, 0.04); + box-shadow: inset 3px 0 0 rgba(245, 200, 106, 0.78); +} + +.nam-browse-hero { + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: minmax(210px, 0.44fr) minmax(0, 1fr) minmax(160px, auto); + align-items: stretch; + gap: 14px; + min-height: 178px; + padding: 12px; + overflow: hidden; + border: 1px solid rgba(245, 200, 106, 0.24); + border-radius: 8px; + background: + radial-gradient(circle at 16% 12%, rgba(245, 200, 106, 0.18), transparent 32%), + radial-gradient(circle at 92% 86%, rgba(69, 179, 107, 0.11), transparent 34%), + linear-gradient(135deg, rgba(35, 34, 30, 0.98), rgba(8, 8, 7, 0.99)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.055), + 0 20px 44px rgba(0, 0, 0, 0.28); +} + +.nam-browse-hero::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 24px), + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 36%, rgba(0, 0, 0, 0.32)); + opacity: 0.48; + pointer-events: none; +} + +.nam-browse-hero[data-audition="true"] { + border-color: rgba(142, 245, 194, 0.38); + background: + radial-gradient(circle at 16% 12%, rgba(142, 245, 194, 0.15), transparent 32%), + radial-gradient(circle at 92% 86%, rgba(245, 200, 106, 0.14), transparent 34%), + linear-gradient(135deg, rgba(24, 36, 29, 0.98), rgba(7, 9, 8, 0.99)); +} + +.nam-browse-hero-art { + position: relative; + min-height: 154px; + overflow: hidden; + display: grid; + align-content: end; + gap: 6px; + padding: 12px; + border: 1px solid rgba(245, 200, 106, 0.2); + border-radius: 8px; + background: + radial-gradient(circle at 52% 16%, rgba(245, 200, 106, 0.18), transparent 46%), + linear-gradient(145deg, rgba(48, 45, 35, 0.96), rgba(8, 8, 7, 0.98)); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + box-shadow: + inset 0 -74px 68px rgba(0, 0, 0, 0.7), + 0 18px 34px rgba(0, 0, 0, 0.28); +} + +.nam-browse-hero-art::after { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.04), transparent 35%, rgba(0, 0, 0, 0.54)); + pointer-events: none; +} + +.nam-browse-hero-art span, +.nam-browse-hero-art strong { + position: relative; + z-index: 1; + width: fit-content; + max-width: 100%; + overflow: hidden; + padding: 3px 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.6); + color: #f8fafc; + font-size: 10px; + font-weight: 900; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-browse-hero-art span { + border: 1px solid rgba(142, 245, 194, 0.24); + color: #8ef5c2; +} + +.nam-browse-hero-copy { + min-width: 0; + display: grid; + align-content: center; + gap: 7px; +} + +.nam-browse-hero-copy > span { + color: #8ef5c2; + font-size: 10px; + font-weight: 950; + text-transform: uppercase; +} + +.nam-browse-hero-copy > strong { + display: -webkit-box; + overflow: hidden; + color: #f8fafc; + font-size: clamp(24px, 2.6vw, 40px); + line-height: 0.98; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.nam-browse-hero-copy > small { + min-width: 0; + overflow: hidden; + color: rgba(226, 232, 240, 0.68); + font-size: 12px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-browse-hero-tags, +.nam-browse-hero-stats { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.nam-browse-hero-tags span, +.nam-browse-hero-stats span { + max-width: 150px; + overflow: hidden; + padding: 4px 8px; + border: 1px solid rgba(142, 245, 194, 0.14); + border-radius: 999px; + color: rgba(226, 232, 240, 0.82); + background: rgba(0, 0, 0, 0.24); + font-size: 10px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-browse-hero-tags span:first-child { + color: #8ef5c2; +} + +.nam-browse-hero-side { + min-width: 0; + display: grid; + align-content: space-between; + justify-items: end; + gap: 10px; +} + +.nam-browse-hero-stats { + justify-content: flex-end; +} + +.nam-browse-hero-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 7px; +} + +.nam-browse-hero-actions > button { + min-height: 34px; + font-weight: 900; +} + +.nam-browse-hero-actions > button:first-child:not(:disabled) { + border-color: rgba(142, 245, 194, 0.44); + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.3), rgba(7, 10, 8, 0.96)), + #101211; + color: #d1fae5; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 0 0 1px rgba(142, 245, 194, 0.1), + 0 10px 22px rgba(0, 0, 0, 0.22); + font-weight: 900; +} + +.nam-browse-hero-actions > button:first-child:not(:disabled):hover { + border-color: rgba(142, 245, 194, 0.62); + filter: brightness(1.04); +} + +.nam-save-tone-modal { + color: #e2e8f0; +} + +.nam-save-tone-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 4px; +} + +.nam-save-tone-form > :first-child, +.nam-save-tone-form textarea, +.nam-save-tone-form .nam-save-tone-favorite { + grid-column: 1 / -1; +} + +.nam-save-tone-form input, +.nam-save-tone-form textarea { + color: #e2e8f0; +} + +.nam-save-tone-favorite { + display: inline-flex; + align-items: center; + gap: 8px; + color: rgba(226, 232, 240, 0.78); + font-size: 12px; + font-weight: 850; +} + +.nam-save-tone-favorite input { + width: 16px; + height: 16px; + accent-color: #45b36b; +} + +.nam-selected-tone-rail { + display: grid; + grid-template-columns: minmax(112px, 0.22fr) minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 104px; + padding: 10px; + border: 1px solid rgba(245, 200, 106, 0.24); + border-radius: 8px; + background: + radial-gradient(circle at 12% 20%, rgba(245, 200, 106, 0.14), transparent 32%), + radial-gradient(circle at 88% 72%, rgba(69, 179, 107, 0.1), transparent 34%), + linear-gradient(180deg, rgba(30, 29, 25, 0.98), rgba(8, 8, 7, 0.98)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.055), + 0 16px 30px rgba(0, 0, 0, 0.22); +} + +.nam-selected-tone-rail[data-audition="true"] { + border-color: rgba(142, 245, 194, 0.34); + background: + radial-gradient(circle at 12% 20%, rgba(142, 245, 194, 0.13), transparent 32%), + radial-gradient(circle at 88% 72%, rgba(245, 200, 106, 0.12), transparent 34%), + linear-gradient(180deg, rgba(24, 34, 28, 0.98), rgba(7, 9, 8, 0.98)); +} + +.nam-selected-tone-art { + position: relative; + min-height: 84px; + overflow: hidden; + display: grid; + align-content: end; + gap: 5px; + padding: 9px; + border: 1px solid rgba(245, 200, 106, 0.18); + border-radius: 7px; + background: + radial-gradient(circle at 50% 24%, rgba(245, 200, 106, 0.18), transparent 48%), + linear-gradient(145deg, rgba(45, 42, 33, 0.96), rgba(8, 8, 7, 0.98)); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + box-shadow: inset 0 -46px 44px rgba(0, 0, 0, 0.62); +} + +.nam-selected-tone-art span, +.nam-selected-tone-art strong { + position: relative; + z-index: 1; + width: fit-content; + max-width: 100%; + overflow: hidden; + padding: 2px 7px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.58); + color: #f8fafc; + font-size: 10px; + font-weight: 900; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-selected-tone-art span { + color: #8ef5c2; +} + +.nam-selected-tone-copy { + min-width: 0; + display: grid; + gap: 4px; +} + +.nam-selected-tone-copy span { + color: #8ef5c2; + font-size: 10px; + font-weight: 900; + text-transform: uppercase; +} + +.nam-selected-tone-copy strong { + min-width: 0; + overflow: hidden; + color: #f8fafc; + font-size: clamp(17px, 1.5vw, 23px); + line-height: 1.08; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-selected-tone-copy small { + min-width: 0; + overflow: hidden; + color: rgba(226, 232, 240, 0.66); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-selected-tone-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 7px; +} + +.nam-selected-tone-actions > button { + min-height: 34px; + font-weight: 900; +} + +.nam-selected-tone-actions > button:first-child:not(:disabled), +.nam-detail-actions > button:first-child:not(:disabled) { + border-color: rgba(142, 245, 194, 0.44); + background: + linear-gradient(180deg, rgba(69, 179, 107, 0.3), rgba(7, 10, 8, 0.96)), + #101211; + color: #d1fae5; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 0 0 1px rgba(142, 245, 194, 0.1), + 0 10px 22px rgba(0, 0, 0, 0.22); + font-weight: 900; +} + +.nam-selected-tone-actions > button:first-child:not(:disabled):hover, +.nam-detail-actions > button:first-child:not(:disabled):hover { + border-color: rgba(142, 245, 194, 0.62); + filter: brightness(1.04); +} + +.nam-result-card[data-view="cards"] { + grid-template-rows: minmax(0, auto) auto auto; +} + +.nam-result-card[data-view="cards"] .nam-result-copy { + display: none; +} + +.nam-card-art > span { + position: absolute; + top: 10px; + left: 10px; +} + +.nam-card-art > strong { + position: absolute; + top: 36px; + left: 10px; + max-width: calc(100% - 56px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-card-titleplate { + position: relative; + z-index: 2; + display: grid; + gap: 3px; + max-width: min(100%, 360px); + padding: 8px 9px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 7px; + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.62), rgba(0, 0, 0, 0.38)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.055), + 0 12px 22px rgba(0, 0, 0, 0.28); + backdrop-filter: blur(8px); +} + +.nam-card-titleplate b { + display: -webkit-box; + overflow: hidden; + color: #f8fafc; + font-size: 13px; + line-height: 1.18; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.nam-card-titleplate small { + min-width: 0; + overflow: hidden; + color: rgba(226, 232, 240, 0.68); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-card-art .nam-card-audition-puck { + margin-top: 1px; +} + +.nam-browser-drawer-full .nam-result-card[data-view="cards"] { + min-height: 278px; +} + +.nam-browser-drawer-full .nam-card-art { + min-height: 176px; +} + +.nam-browser-drawer-full .nam-results[data-view="cards"] { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +.nam-results-header { + display: grid; + grid-template-columns: 98px minmax(0, 1.22fr) minmax(150px, 0.78fr) minmax(112px, 0.48fr) minmax(112px, auto); + align-items: center; + gap: 9px; + padding: 0 10px 2px; + color: rgba(226, 232, 240, 0.44); + font-size: 9px; + font-weight: 900; + text-transform: uppercase; +} + +.nam-result-card[data-view="list"] { + grid-template-columns: 98px minmax(0, 1.22fr) minmax(150px, 0.78fr) minmax(112px, 0.48fr) minmax(112px, auto); + align-items: center; + gap: 9px; + min-height: 104px; + padding: 9px; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.018)), + rgba(0, 0, 0, 0.15); +} + +.nam-result-card[data-view="list"]:hover { + border-color: rgba(245, 200, 106, 0.28); + background: + linear-gradient(90deg, rgba(245, 200, 106, 0.07), rgba(69, 179, 107, 0.03)), + rgba(0, 0, 0, 0.18); +} + +.nam-result-card[data-view="list"] .nam-card-art { + width: 100%; + height: 72px; + min-height: 0; + aspect-ratio: auto; + border-color: rgba(236, 196, 110, 0.12); +} + +.nam-result-card[data-view="list"] .nam-card-titleplate, +.nam-result-card[data-view="list"] .nam-card-audition-puck { + display: none; +} + +.nam-result-card[data-view="list"] .nam-card-art > span { + top: 8px; + left: 8px; +} + +.nam-result-card[data-view="list"] .nam-card-art > strong { + top: auto; + bottom: 8px; + left: 8px; + max-width: calc(100% - 16px); +} + +.nam-result-card[data-view="list"] .nam-result-copy { + gap: 5px; +} + +.nam-result-card[data-view="list"] .nam-result-copy strong { + -webkit-line-clamp: 1; +} + +.nam-result-badges { + min-width: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; +} + +.nam-result-badges span { + max-width: 132px; + overflow: hidden; + padding: 3px 7px; + border: 1px solid rgba(142, 245, 194, 0.14); + border-radius: 999px; + color: rgba(226, 232, 240, 0.78); + background: rgba(0, 0, 0, 0.22); + font-size: 10px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nam-result-badges span:first-child { + color: #8ef5c2; +} + +.nam-result-card[data-view="cards"] .nam-result-badges { + display: none; +} + +.nam-result-card[data-view="list"] .nam-stats { + justify-content: flex-start; +} + +.nam-result-card[data-view="list"] .nam-result-actions { + align-self: stretch; + justify-content: flex-end; +} + +@media (max-width: 980px) { + .nam-preset-manager { + left: 12px; + right: 12px; + } + + .nam-preset-manager-grid { + grid-template-columns: 1fr; + } + + .nam-preset-save-fields { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .nam-user-preset-row { + grid-template-columns: minmax(0, 1fr) repeat(4, 30px); + } + + .nam-browse-hero { + grid-template-columns: minmax(120px, 0.34fr) minmax(0, 1fr); + } + + .nam-browse-hero-side { + grid-column: 1 / -1; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + justify-items: stretch; + } + + .nam-browse-hero-stats { + justify-content: flex-start; + } + + .nam-selected-tone-rail { + grid-template-columns: minmax(92px, 0.32fr) minmax(0, 1fr); + } + + .nam-selected-tone-actions { + grid-column: 1 / -1; + justify-content: flex-start; + } + + .nam-library-summary { + grid-template-columns: 1fr; + align-items: start; + } + + .nam-catalog-health, + .nam-active-filters { + justify-content: flex-start; + } +} + +@media (max-width: 520px) { + .nam-browse-hero { + grid-template-columns: 1fr; + } + + .nam-browse-hero-art { + min-height: 150px; + } + + .nam-browse-hero-side { + grid-template-columns: 1fr; + justify-items: start; + } + + .nam-browse-hero-actions, + .nam-browse-hero-stats { + justify-content: flex-start; + } + + .nam-save-tone-form { + grid-template-columns: 1fr; + } + + .nam-user-preset-row { + grid-template-columns: repeat(8, 30px); + } + + .nam-user-preset-row > button:first-child { + grid-column: 1 / -1; + } +} + +.builtin-empty { + padding: 16px 8px; + color: #737373; + font-size: 11px; + text-align: center; +} + +.fx-slot-name:hover { + color: #2563eb; +} + +.fx-remove-btn { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid #404040; + border-radius: 4px; + color: #737373; + font-size: 18px; + cursor: pointer; + transition: all 0.2s; +} + +.fx-remove-btn:hover { + background: #dc2626; + border-color: #dc2626; + color: white; +} + +/* Empty state */ +.fx-empty-state { + text-align: center; + padding: 60px 20px; + color: #737373; +} + +.fx-empty-state p { + margin: 8px 0; +} + +.fx-empty-state .hint { + font-size: 13px; + color: #525252; +} + +/* Old single-column styles (kept for backward compatibility) */ +.fx-chain-panel { + background: #171717; + border: 1px solid #404040; + border-radius: 8px; + width: 90%; + max-width: 600px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.fx-chain-content { + flex: 1; + overflow-y: auto; + padding: 16px; + background: #0a0a0a; +} + +.fx-slots { + display: flex; + flex-direction: column; + gap: 8px; +} + +.fx-slot { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background: #1a1a1a; + border: 1px solid #404040; + border-radius: 6px; + transition: all 0.2s; +} + +.fx-slot:hover { + border-color: #2563eb; + background: #1f1f1f; +} + +.fx-info { + flex: 1; +} + +.fx-name { + font-weight: 600; + color: white; + margin-bottom: 4px; +} + +.fx-index { + font-size: 12px; + color: #737373; +} + +.fx-controls { + display: flex; + gap: 8px; +} + +.fx-btn { + padding: 6px 14px; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.edit-btn { + background: #2563eb; + color: white; +} + +.edit-btn:hover { + background: #1d4ed8; +} + +.bypass-btn { + background: #f59e0b; + color: white; +} + +.bypass-btn:hover { + background: #d97706; +} + +.remove-btn { + background: #dc2626; + color: white; +} + +.remove-btn:hover { + background: #b91c1c; +} + +.close-btn-small { + background: #404040; + color: white; } .close-btn-small:hover { @@ -879,3 +5287,328 @@ color: #737373; text-align: center; } + +.nam-rack-prompt-modal { + width: min(460px, calc(100vw - 32px)); + border-color: rgba(224, 161, 73, 0.34); + border-radius: 8px; + color: #e9ebee; + background: + radial-gradient(circle at 18% 0%, rgba(224, 161, 73, 0.08), transparent 38%), + linear-gradient(180deg, #171b20, #0e1115); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.68), inset 0 1px rgba(255, 255, 255, 0.035); +} + +.nam-rack-prompt-modal > div:first-child { + border-bottom-color: rgba(255, 255, 255, 0.09); +} + +.nam-rack-prompt-modal > div:first-child h2 { + color: #f0f1f2; + font-size: 17px; + font-weight: 650; + letter-spacing: -0.015em; +} + +.nam-rack-prompt-modal > div:last-child { + border-top-color: rgba(255, 255, 255, 0.09); +} + +.nam-rack-prompt-body { + display: grid; + gap: 14px; +} + +.nam-rack-prompt-body p { + margin: 0; + color: #9299a3; + font-size: 12px; + line-height: 1.55; +} + +.nam-rack-prompt-body input, +.nam-rack-prompt-body textarea { + width: 100%; + min-height: 40px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.13); + border-radius: 5px; + outline: none; + color: #f1f2f3; + background: #090c10; + font: inherit; + font-size: 12px; + box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.45); +} + +.nam-rack-prompt-body textarea { + min-height: 96px; + padding-block: 10px; + resize: vertical; +} + +.nam-rack-prompt-body input:focus, +.nam-rack-prompt-body textarea:focus { + border-color: rgba(224, 161, 73, 0.68); + box-shadow: 0 0 0 2px rgba(224, 161, 73, 0.1), inset 0 1px 4px rgba(0, 0, 0, 0.45); +} + +.nam-rack-prompt-cancel, +.nam-rack-prompt-confirm { + min-height: 36px; + padding: 0 15px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 5px; + color: #b8bec6; + background: linear-gradient(180deg, #20252c, #15191e); + font-size: 11px; + font-weight: 650; +} + +.nam-rack-prompt-confirm { + border-color: rgba(224, 161, 73, 0.58); + color: #241507; + background: linear-gradient(180deg, #f2b960, #ca8432); +} + +.nam-rack-prompt-confirm[data-destructive="true"] { + border-color: rgba(229, 105, 91, 0.6); + color: #fff4f2; + background: linear-gradient(180deg, #c95a50, #8d3731); +} + +.nam-rack-prompt-cancel:hover { color: #fff; border-color: rgba(255, 255, 255, 0.2); } +.nam-rack-prompt-confirm:hover { filter: brightness(1.05); } + +/* NAM Rack preset library: visually belongs to the premium graphite/amber shell. */ +.nam-preset-manager { + z-index: 70; + top: clamp(154px, 18vh, 176px); + right: auto; + left: 50%; + width: min(1040px, calc(100% - 40px)); + max-width: none; + max-height: calc(100% - 190px); + box-sizing: border-box; + gap: 12px; + padding: 16px; + overflow: auto; + transform: translateX(-50%); + border-color: rgba(224, 161, 73, 0.34); + border-radius: 8px; + background: + radial-gradient(circle at 50% -24%, rgba(224, 161, 73, 0.1), transparent 42%), + linear-gradient(180deg, rgba(22, 26, 32, 0.99), rgba(8, 11, 15, 0.99)); + box-shadow: + 0 34px 90px rgba(0, 0, 0, 0.76), + inset 0 1px rgba(255, 255, 255, 0.045), + inset 0 0 0 1px rgba(0, 0, 0, 0.55); + font-family: Inter, "Segoe UI Variable", "Segoe UI", Arial, sans-serif; + scrollbar-width: thin; + scrollbar-color: #414955 transparent; +} + +.nam-preset-manager-head { + min-height: 44px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.085); +} + +.nam-preset-manager-head > div { gap: 4px; } + +.nam-preset-manager-head span, +.nam-preset-column > span { + color: #d99a48; + font-size: 9px; + font-weight: 720; + letter-spacing: 0.12em; +} + +.nam-preset-manager-head strong { + color: #f0f1f2; + font-size: 20px; + font-weight: 620; + letter-spacing: -0.02em; +} + +.nam-preset-manager-head > button, +.nam-preset-save-row button, +.nam-preset-transfer-row button, +.nam-user-preset-row > button:last-child { + border-color: rgba(255, 255, 255, 0.11); + border-radius: 5px; + color: #aeb4bc; + background: linear-gradient(180deg, #20252c, #13171c); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.035); +} + +.nam-preset-manager-head > button:hover:not(:disabled), +.nam-preset-transfer-row button:hover:not(:disabled) { + border-color: rgba(224, 161, 73, 0.42); + color: #f2c17a; + background: linear-gradient(180deg, #282d34, #171b20); +} + +.nam-preset-search { + height: 38px; + border-color: rgba(255, 255, 255, 0.105); + border-radius: 5px; + color: #747c87; + background: #090c10; + box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.4); +} + +.nam-preset-search input, +.nam-preset-save-row input { + color: #e8eaec; + font-size: 11px; +} + +.nam-preset-search input::placeholder, +.nam-preset-save-row input::placeholder { color: #5f6670; } + +.nam-preset-filter-row { gap: 6px; } + +.nam-preset-filter-row button { + min-height: 27px; + padding-inline: 9px; + border-radius: 4px; + color: #858d97; + background: #12161b; + font-weight: 650; +} + +.nam-preset-filter-row button small { + color: #171008; + background: #d79a4a; + font-weight: 760; +} + +.nam-preset-filter-row button[data-active="true"] { + border-color: rgba(224, 161, 73, 0.48); + color: #f1bd72; + background: rgba(224, 161, 73, 0.1); + box-shadow: inset 0 0 0 1px rgba(224, 161, 73, 0.05); +} + +.nam-preset-save-fields { gap: 6px; } + +.nam-preset-save-row input { + border-color: rgba(255, 255, 255, 0.1); + border-radius: 5px; + background: #0b0e12; +} + +.nam-preset-save-row input:focus, +.nam-preset-search:focus-within { + border-color: rgba(224, 161, 73, 0.55); + box-shadow: 0 0 0 2px rgba(224, 161, 73, 0.08), inset 0 1px 4px rgba(0, 0, 0, 0.42); +} + +.nam-preset-save-row button { + min-width: 92px; + border-color: rgba(224, 161, 73, 0.58); + color: #211509; + background: linear-gradient(180deg, #ecb25c, #c98331); + font-weight: 720; +} + +.nam-preset-save-row button:hover:not(:disabled) { + border-color: rgba(255, 197, 108, 0.78); + background: linear-gradient(180deg, #f2bd6a, #d28e3b); +} + +.nam-preset-transfer-row button { + min-height: 32px; + border-color: rgba(255, 255, 255, 0.1); + color: #aab0b8; + background: #14181d; + font-weight: 650; +} + +.nam-preset-status { + border-color: rgba(224, 161, 73, 0.2); + color: #b7bdc5; + background: rgba(224, 161, 73, 0.065); +} + +.nam-preset-manager-grid { gap: 12px; } + +.nam-preset-column { + gap: 9px; + padding: 11px; + border-color: rgba(255, 255, 255, 0.085); + border-radius: 6px; + background: rgba(4, 6, 9, 0.38); +} + +.nam-preset-list { gap: 7px; } + +.nam-preset-list > button, +.nam-user-preset-row > button:first-child { + min-height: 52px; + padding: 9px 10px; + border-color: rgba(255, 255, 255, 0.085); + border-radius: 5px; + color: #a9afb7; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.032), rgba(255, 255, 255, 0.012)); +} + +.nam-preset-list > button:hover:not(:disabled), +.nam-user-preset-row > button:first-child:hover:not(:disabled) { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.045); +} + +.nam-preset-list > button[data-active="true"] { + border-color: rgba(224, 161, 73, 0.5); + background: linear-gradient(90deg, rgba(224, 161, 73, 0.13), rgba(255, 255, 255, 0.025)); + box-shadow: inset 2px 0 #d99947; +} + +.nam-preset-list strong { + color: #e7e9eb; + font-weight: 650; +} + +.nam-preset-list small, +.nam-preset-list em { color: #747c86; } + +.nam-user-preset-row { + grid-template-columns: minmax(150px, 1fr) repeat(8, 28px); + gap: 4px; +} + +.nam-user-preset-row > button:not(:first-child) { + width: 28px; + min-height: 28px; + border-radius: 4px; + color: #7d858f; + background: #14181d; +} + +.nam-user-preset-row[data-favorite="true"] > button:nth-child(2) { + border-color: rgba(224, 161, 73, 0.46); + color: #f2b963; + background: rgba(224, 161, 73, 0.11); +} + +.nam-user-preset-row > button:first-child em { + border-color: rgba(224, 161, 73, 0.16); + color: #9aa1aa; + background: rgba(224, 161, 73, 0.055); +} + +.nam-preset-manager button:disabled { cursor: default; opacity: 0.44; } + +@media (max-width: 980px) { + .nam-preset-manager { + top: 142px; + width: calc(100% - 24px); + max-height: calc(100% - 158px); + padding: 13px; + } + + .nam-preset-save-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .nam-preset-manager-grid { grid-template-columns: 1fr; } +} diff --git a/frontend/src/components/FXChainPanel.tsx b/frontend/src/components/FXChainPanel.tsx index 4a477d3..8a46efa 100644 --- a/frontend/src/components/FXChainPanel.tsx +++ b/frontend/src/components/FXChainPanel.tsx @@ -27,15 +27,27 @@ import { Star, ExternalLink, } from "lucide-react"; -import { nativeBridge } from "../services/NativeBridge"; +import { + nativeBridge, + type BuiltInPluginAddress, + type PluginParameterInfo, + type PluginScanReport, +} from "../services/NativeBridge"; import { PitchCorrectorPanel } from "./PitchCorrectorPanel"; import { BuiltInPluginPanel } from "./BuiltInPluginPanel"; import { MIDIFXControls } from "./MIDIFXControls"; import { useDAWStore } from "../store/useDAWStore"; -import { pluginAutomationParamId } from "../store/automationParams"; +import { registerScopedActionExecutor } from "../store/actionRegistry"; +import { builtInAutomationParamId, pluginAutomationParamId } from "../store/automationParams"; import { useShallow } from "zustand/react/shallow"; import { guardModalContextMenu } from "../utils/modalEventGuards"; -import { Button, Input, Select } from "./ui"; +import { + activateShortcutContext, + getActiveShortcutContext, + registerShortcutSurface, + type ShortcutSurfaceHandler, +} from "../utils/shortcutContext"; +import { Button, Input, ProfiledRangeInput, Select } from "./ui"; import { EQGraph, CompressorGraph, @@ -82,11 +94,22 @@ interface S13FXSlider { enumNames?: string[]; } -interface PluginParam { - index: number; - name: string; - value: number; - text: string; +type PluginParam = PluginParameterInfo; + +function formatPluginParameterValue(param: PluginParam, normalizedValue: number): string { + if (!param.builtIn) return `${Math.round(normalizedValue * 100)}%`; + const minimum = Number(param.min ?? 0); + const maximum = Number(param.max ?? 1); + let rawValue = minimum + normalizedValue * (maximum - minimum); + if (param.discrete) rawValue = Math.round(rawValue); + if (param.type === "toggle") return rawValue >= 0.5 ? "On" : "Off"; + if (param.type === "enum") { + return param.enumOptions?.find((option) => Math.round(option.value) === Math.round(rawValue))?.label + ?? String(Math.round(rawValue)); + } + const span = Math.abs(maximum - minimum); + const decimals = span <= 2 ? 2 : span <= 50 ? 1 : 0; + return `${rawValue.toFixed(decimals)}${param.unit ? ` ${param.unit}` : ""}`; } interface Plugin { @@ -94,6 +117,7 @@ interface Plugin { manufacturer: string; category: string; fileOrIdentifier: string; + identifier?: string; isInstrument: boolean; hasARA?: boolean; snapshot?: string; @@ -101,6 +125,21 @@ interface Plugin { instrumentMode?: number; } +function getPluginCategoryTokens(category: string): string[] { + const categories = new Map(); + for (const rawToken of category.split("|")) { + const token = rawToken.trim(); + if (token) categories.set(token.toLowerCase(), token); + } + return Array.from(categories.values()); +} + +function getPluginReference(plugin: Plugin): string { + return plugin.pluginType === "s13fx" || plugin.pluginType === "builtin" + ? plugin.fileOrIdentifier + : plugin.identifier || plugin.fileOrIdentifier; +} + // Map VST3 category substrings to Lucide icons and colors const CATEGORY_ICON_MAP: Array<{ match: string; @@ -138,7 +177,8 @@ function getPluginDisplayName( ) { if (!pluginPath) return "Instrument"; const knownPlugin = plugins.find( - (plugin) => plugin.fileOrIdentifier === pluginPath, + (plugin) => + plugin.fileOrIdentifier === pluginPath || plugin.identifier === pluginPath, ); if (knownPlugin) return knownPlugin.name; @@ -155,7 +195,11 @@ export function FXChainPanel({ const { updateTrack, addTrackFXWithUndo, + addTrackBuiltInFXWithUndo, + reorderTrackFXWithUndo, removeTrackFXWithUndo, + removeMasterFXWithUndo, + toggleFXSlotBypassWithUndo, loadInstrumentWithUndo, removeInstrumentWithUndo, clearTrackSamplerSampleWithUndo, @@ -175,7 +219,11 @@ export function FXChainPanel({ useShallow((s) => ({ updateTrack: s.updateTrack, addTrackFXWithUndo: s.addTrackFXWithUndo, + addTrackBuiltInFXWithUndo: s.addTrackBuiltInFXWithUndo, + reorderTrackFXWithUndo: s.reorderTrackFXWithUndo, removeTrackFXWithUndo: s.removeTrackFXWithUndo, + removeMasterFXWithUndo: s.removeMasterFXWithUndo, + toggleFXSlotBypassWithUndo: s.toggleFXSlotBypassWithUndo, loadInstrumentWithUndo: s.loadInstrumentWithUndo, removeInstrumentWithUndo: s.removeInstrumentWithUndo, clearTrackSamplerSampleWithUndo: s.clearTrackSamplerSampleWithUndo, @@ -196,6 +244,8 @@ export function FXChainPanel({ const [fxSlots, setFxSlots] = useState([]); const [loading, setLoading] = useState(false); const [draggedIndex, setDraggedIndex] = useState(null); + const [selectedFxIndex, setSelectedFxIndex] = useState(null); + const availablePluginSearchRef = useRef(null); const [addingPlugin, setAddingPlugin] = useState(null); const [bypassedFx, setBypassedFx] = useState>(new Set()); const [expandedS13FX, setExpandedS13FX] = useState(null); @@ -204,9 +254,6 @@ export function FXChainPanel({ const [expandedPitchCorrector, setExpandedPitchCorrector] = useState< number | null >(null); - const [expandedBuiltInFX, setExpandedBuiltInFX] = useState( - null, - ); const [expandedFallbackInstrument, setExpandedFallbackInstrument] = useState(false); const [showPresetMenu, setShowPresetMenu] = useState(false); @@ -215,6 +262,14 @@ export function FXChainPanel({ null, ); + useEffect(() => { + setSelectedFxIndex((current) => ( + current !== null && fxSlots.some((fx) => fx.index === current) + ? current + : null + )); + }, [fxSlots]); + // Plugin parameter list state (per-slot) const [expandedParamsFx, setExpandedParamsFx] = useState(null); const [pluginParams, setPluginParams] = useState([]); @@ -231,19 +286,27 @@ export function FXChainPanel({ // MIDI Learn state const [midiLearnActive, setMidiLearnActive] = useState<{ fxIndex: number; - paramIndex: number; + paramKey: string; } | null>(null); const midiLearnTimerRef = useRef | null>(null); const handleStartMIDILearn = useCallback( - async (fxIndex: number, paramIndex: number) => { + async (fxIndex: number, param: PluginParam) => { // Cancel any existing learn session if (midiLearnActive) { await nativeBridge.cancelPluginMIDILearn(); if (midiLearnTimerRef.current) clearTimeout(midiLearnTimerRef.current); } - setMidiLearnActive({ fxIndex, paramIndex }); - await nativeBridge.startPluginMIDILearn(trackId, fxIndex, paramIndex); + const paramKey = param.builtIn && param.paramId ? `builtin:${param.paramId}` : `plugin:${param.index}`; + setMidiLearnActive({ fxIndex, paramKey }); + if (param.builtIn && param.paramId) { + await nativeBridge.startBuiltInMIDILearn( + { trackId, chain: chainType === "input" ? "input" : "track", fxIndex }, + param.paramId, + ); + } else { + await nativeBridge.startPluginMIDILearn(trackId, fxIndex, param.index, chainType === "input"); + } // Auto-cancel after 10 seconds midiLearnTimerRef.current = setTimeout(async () => { await nativeBridge.cancelPluginMIDILearn(); @@ -251,7 +314,7 @@ export function FXChainPanel({ midiLearnTimerRef.current = null; }, 10000); }, - [midiLearnActive, trackId], + [chainType, midiLearnActive, trackId], ); const handleCancelMIDILearn = useCallback(async () => { @@ -324,6 +387,9 @@ export function FXChainPanel({ // Plugin browser state const [plugins, setPlugins] = useState([]); const [pluginsLoading, setPluginsLoading] = useState(false); + const [pluginScanReport, setPluginScanReport] = + useState(null); + const [pluginScanError, setPluginScanError] = useState(""); const [searchTerm, setSearchTerm] = useState(""); const [categoryFilter, setCategoryFilter] = useState("All"); const currentTrack = tracks.find((track) => track.id === trackId); @@ -334,9 +400,11 @@ export function FXChainPanel({ name === "OpenStudio Piano" || name === "OpenStudio Drums" || name === "OpenStudio Basic Synth" || + name === "OpenStudio Clean Guitar" || name === "Studio13 Piano" || name === "Studio13 Drums" || - name === "Studio13 Basic Synth"; + name === "Studio13 Basic Synth" || + name === "Studio13 Clean Guitar"; const hasBuiltInInstrumentFX = chainType === "track" && @@ -479,6 +547,23 @@ export function FXChainPanel({ void loadAvailablePlugins(); }, [chainType, loadAvailablePlugins, loadPlugins, trackId]); + useEffect(() => { + const handleCatalogChanged = () => { + void loadAvailablePlugins(); + }; + + window.addEventListener( + "openstudio:plugin-catalog-changed", + handleCatalogChanged, + ); + return () => { + window.removeEventListener( + "openstudio:plugin-catalog-changed", + handleCatalogChanged, + ); + }; + }, [loadAvailablePlugins]); + useEffect(() => { return subscribeToFXChainChanged((detail) => { if (detail.trackId !== trackId || detail.chainType !== chainType) { @@ -489,20 +574,43 @@ export function FXChainPanel({ }); }, [chainType, loadPlugins, trackId]); - const handleScan = async () => { + const handleScan = async (forceRescan: boolean) => { setPluginsLoading(true); + setPluginScanError(""); try { - await nativeBridge.scanForPlugins(); + setPluginScanReport(await nativeBridge.scanForPlugins(forceRescan)); await loadAvailablePlugins(); } catch (e) { console.error("[FXChain] Failed to scan:", e); + setPluginScanError( + "Scan did not complete; the previous plug-in catalog was preserved.", + ); } finally { setPluginsLoading(false); } }; + const handleAddPluginScanFolder = async () => { + setPluginScanError(""); + try { + const folder = ( + await nativeBridge.browseForFolder("Choose a plug-in scan folder") + ).trim(); + if (!folder) return; + if (!(await nativeBridge.addPluginScanPath(folder))) { + setPluginScanError("That plug-in folder could not be added."); + return; + } + await handleScan(false); + } catch (error) { + console.error("[FXChain] Failed to add plug-in scan folder:", error); + setPluginScanError("That plug-in folder could not be added."); + } + }; + const handleAddPlugin = async (plugin: Plugin) => { - setAddingPlugin(plugin.fileOrIdentifier); + const pluginReference = getPluginReference(plugin); + setAddingPlugin(pluginReference); try { let success = false; const expectedLength = @@ -517,11 +625,10 @@ export function FXChainPanel({ if (chainType === "master") { success = await nativeBridge.addMasterBuiltInFX(plugin.name); } else { - const isInputFX = chainType === "input"; - success = await nativeBridge.addTrackBuiltInFX( + success = await addTrackBuiltInFXWithUndo( trackId, plugin.name, - isInputFX, + chainType, ); if (success && plugin.isInstrument && chainType === "track") { updateTrack(trackId, { @@ -547,25 +654,25 @@ export function FXChainPanel({ ); } } else if (chainType === "master") { - success = await nativeBridge.addMasterFX(plugin.fileOrIdentifier); + success = await nativeBridge.addMasterFX(pluginReference); } else if (plugin.isInstrument && chainType === "track" && trackId) { // Instrument plugins (VSTi) must be loaded via loadInstrument so the // track is set to Instrument type and receives MIDI for synthesis. success = await loadInstrumentWithUndo( trackId, - plugin.fileOrIdentifier, + pluginReference, ); if (success) { notifyInstrumentChanged({ trackId, - instrumentPlugin: plugin.fileOrIdentifier, + instrumentPlugin: pluginReference, }); await nativeBridge.openInstrumentEditor(trackId); } } else if (chainType === "input" || chainType === "track") { success = await addTrackFXWithUndo( trackId, - plugin.fileOrIdentifier, + pluginReference, chainType, ); } @@ -577,6 +684,7 @@ export function FXChainPanel({ chainType, trackId, fileOrIdentifier: plugin.fileOrIdentifier, + identifier: plugin.identifier, }); let updatedFx: FXSlot[] = []; if (chainType === "master") { @@ -686,7 +794,7 @@ export function FXChainPanel({ if (plugin.pluginType === "builtin" && updatedFx.length > 0) { const lastFx = updatedFx[updatedFx.length - 1]; if (lastFx) { - setExpandedBuiltInFX(lastFx.index); + await handleOpenBuiltInEditor(lastFx); setExpandedPitchCorrector(null); } } @@ -726,6 +834,40 @@ export function FXChainPanel({ } }; + async function handleOpenBuiltInEditor(fx: FXSlot) { + const title = fx.name || "OpenStudio Plugin"; + const address: BuiltInPluginAddress = { + trackId, + chain: chainType, + fxIndex: fx.index, + }; + const isNAMRack = title.toLowerCase().includes("nam rack"); + const sessionId = JSON.stringify({ + address, + title, + fallbackName: title, + }); + + try { + const opened = await nativeBridge.openBuiltInPluginEditorWindow( + sessionId, + { + x: isNAMRack ? 140 : 220, + y: isNAMRack ? 70 : 130, + width: isNAMRack ? 1320 : 980, + height: isNAMRack ? 860 : 720, + }, + ); + if (!opened) { + console.error( + `[FXChain] Failed to open built-in editor window for ${title}`, + ); + } + } catch (e) { + console.error("[FXChain] Failed to open built-in editor window:", e); + } + } + const handleRemoveInstrument = async () => { try { if (currentTrack?.samplerSamplePath && !currentTrack.instrumentPlugin) { @@ -743,8 +885,7 @@ export function FXChainPanel({ try { let success = false; if (chainType === "master") { - await nativeBridge.removeMasterFX(fxIndex); - success = true; + success = await removeMasterFXWithUndo(fxIndex); } else if (chainType === "input" || chainType === "track") { success = await removeTrackFXWithUndo(trackId, fxIndex, chainType); } @@ -752,9 +893,6 @@ export function FXChainPanel({ if (success) { console.log(`[FXChain] Removed ${chainType} FX ${fxIndex}`); await loadPlugins(); - if (chainType === "master") { - notifyFXChainChanged({ trackId, chainType }); - } } } catch (e) { console.error("[FXChain] Failed to remove plugin:", e); @@ -762,38 +900,148 @@ export function FXChainPanel({ }; const handleToggleBypass = async (fxIndex: number) => { - const isBypassed = bypassedFx.has(fxIndex); - const newBypassed = !isBypassed; try { - let success = false; - if (chainType === "master") { - success = await nativeBridge.bypassMasterFX(fxIndex, newBypassed); - } else if (chainType === "input") { - success = await nativeBridge.bypassTrackInputFX( - trackId, - fxIndex, - newBypassed, - ); - } else { - success = await nativeBridge.bypassTrackFX( - trackId, - fxIndex, - newBypassed, - ); - } + const success = await toggleFXSlotBypassWithUndo(trackId, fxIndex, chainType); if (success) { - setBypassedFx((prev) => { - const next = new Set(prev); - if (newBypassed) next.add(fxIndex); - else next.delete(fxIndex); - return next; - }); + await loadPlugins(); } } catch (e) { console.error("[FXChain] Failed to toggle bypass:", e); } }; + const fxShortcutSessionId = `fx-chain:${chainType}:${trackId}`; + const fxActionExecutorRef = useRef< + (actionId: string) => ReturnType + >(() => "unmatched"); + fxActionExecutorRef.current = (actionId) => { + if (actionId === "fx.close") { + onClose(); + return "handled"; + } + if (actionId === "track.openSelectedFxChain") { + return chainType === "track" && useDAWStore.getState().selectedTrackId === trackId + ? "handled" + : "claimed_noop"; + } + if (actionId === "fx.add") { + const searchInput = availablePluginSearchRef.current; + if (!searchInput) return "claimed_noop"; + searchInput.scrollIntoView({ block: "nearest", inline: "nearest" }); + searchInput.focus(); + searchInput.select(); + return "handled"; + } + if (actionId === "fx.openInstrumentEditor") { + if (chainType !== "track") return "claimed_noop"; + if (hasLoadedInstrument) void handleOpenInstrumentEditor(); + else if (hasFallbackInstrument) setExpandedFallbackInstrument((expanded) => !expanded); + else return "claimed_noop"; + return "handled"; + } + if (actionId === "fx.removeInstrument") { + if (chainType !== "track" || (!hasLoadedInstrument && !hasFallbackInstrument)) { + return "claimed_noop"; + } + void handleRemoveInstrument(); + return "handled"; + } + if ( + actionId !== "fx.removeSelected" + && actionId !== "fx.toggleSelectedBypass" + && actionId !== "fx.openSelectedEditor" + && actionId !== "fx.toggleSelectedAB" + && actionId !== "fx.reloadSelectedScript" + && actionId !== "fx.toggleSelectedParameters" + && actionId !== "fx.toggleSelectedPresets" + ) return "unmatched"; + + const selectedFx = selectedFxIndex === null + ? undefined + : fxSlots.find((fx) => fx.index === selectedFxIndex); + if (!selectedFx) return "claimed_noop"; + + if (actionId === "fx.removeSelected") { + void handleRemove(selectedFx.index); + return "handled"; + } + if (actionId === "fx.toggleSelectedBypass") { + void handleToggleBypass(selectedFx.index); + return "handled"; + } + if (actionId === "fx.toggleSelectedAB") { + if (selectedFx.type === "s13fx") return "claimed_noop"; + togglePluginAB(trackId, selectedFx.index, chainType === "input"); + return "handled"; + } + if (actionId === "fx.reloadSelectedScript") { + if (selectedFx.type !== "s13fx") return "claimed_noop"; + void handleReloadS13FX(selectedFx.index); + return "handled"; + } + if (actionId === "fx.toggleSelectedParameters") { + if (selectedFx.type === "s13fx" || selectedFx.type === "builtin") return "claimed_noop"; + void handleToggleParams(selectedFx.index); + return "handled"; + } + if (actionId === "fx.toggleSelectedPresets") { + if (selectedFx.type === "s13fx") return "claimed_noop"; + void handleTogglePluginPresets(selectedFx.index); + return "handled"; + } + + if (selectedFx.type === "builtin") { + void handleOpenBuiltInEditor(selectedFx); + } else { + void handleOpenEditor(selectedFx.index); + } + return "handled"; + }; + + useEffect(() => { + const context = { kind: "plugin", sessionId: fxShortcutSessionId } as const; + const fallback = getActiveShortcutContext(); + const unregisterSurface = registerShortcutSurface( + context, + () => "unmatched", + fallback, + ); + const unregisterActions = registerScopedActionExecutor( + context, + (actionId) => fxActionExecutorRef.current(actionId), + [ + "fx.close", + "fx.add", + ...(chainType === "track" ? ["track.openSelectedFxChain"] : []), + ...(chainType === "track" && (hasLoadedInstrument || hasFallbackInstrument) + ? ["fx.openInstrumentEditor", "fx.removeInstrument"] + : []), + ...(selectedFxIndex !== null ? [ + "fx.removeSelected", + "fx.toggleSelectedBypass", + "fx.openSelectedEditor", + ...(fxSlots.find((slot) => slot.index === selectedFxIndex)?.type !== "s13fx" + ? ["fx.toggleSelectedAB", "fx.toggleSelectedPresets"] + : ["fx.reloadSelectedScript"]), + ...(["s13fx", "builtin"].includes( + fxSlots.find((slot) => slot.index === selectedFxIndex)?.type ?? "", + ) ? [] : ["fx.toggleSelectedParameters"]), + ] : []), + ], + ); + return () => { + unregisterActions(); + unregisterSurface(); + }; + }, [ + chainType, + fxShortcutSessionId, + fxSlots, + hasFallbackInstrument, + hasLoadedInstrument, + selectedFxIndex, + ]); + const handleSetPrecisionOverride = useCallback( async (fxIndex: number, mode: "auto" | "float32") => { setPrecisionUpdatingFx(fxIndex); @@ -850,17 +1098,12 @@ export function FXChainPanel({ if (chainType === "master") { // Master FX reorder not yet supported success = false; - } else if (chainType === "input") { - success = await nativeBridge.reorderTrackInputFX( - trackId, - draggedIndex, - dropIndex, - ); } else { - success = await nativeBridge.reorderTrackFX( + success = await reorderTrackFXWithUndo( trackId, draggedIndex, dropIndex, + chainType, ); } @@ -970,11 +1213,9 @@ export function FXChainPanel({ const handleAutomateParam = (fxIndex: number, param: PluginParam) => { if (chainType === "master") return; // Master automation not supported - const automationParam = pluginAutomationParamId( - chainType === "input", - fxIndex, - param.index, - ); + const automationParam = param.builtIn && param.paramId + ? builtInAutomationParamId(chainType === "input", fxIndex, param.paramId) + : pluginAutomationParamId(chainType === "input", fxIndex, param.index); addAutomationLane( trackId, automationParam, @@ -984,30 +1225,34 @@ export function FXChainPanel({ const handlePluginParamChange = async ( fxIndex: number, - paramIndex: number, + changedParam: PluginParam, value: number, ) => { const isInputFX = chainType === "input"; - const automationParam = pluginAutomationParamId( - isInputFX, - fxIndex, - paramIndex, - ); + const automationParam = changedParam.builtIn && changedParam.paramId + ? builtInAutomationParamId(isInputFX, fxIndex, changedParam.paramId) + : pluginAutomationParamId(isInputFX, fxIndex, changedParam.index); setAutomationWriteValue(trackId, automationParam, value); setPluginParams((params) => params.map((param) => - param.index === paramIndex - ? { ...param, value, text: `${Math.round(value * 100)}%` } + param.index === changedParam.index && param.paramId === changedParam.paramId + ? { ...param, value, text: formatPluginParameterValue(changedParam, value) } : param, ), ); - await nativeBridge.setPluginParameter( - trackId, - fxIndex, - isInputFX, - paramIndex, - value, - ); + if (changedParam.builtIn && changedParam.paramId) { + const minimum = Number(changedParam.min ?? 0); + const maximum = Number(changedParam.max ?? 1); + let rawValue = minimum + value * (maximum - minimum); + if (changedParam.discrete) rawValue = Math.round(rawValue); + await nativeBridge.setBuiltInPluginParam( + { trackId, chain: isInputFX ? "input" : "track", fxIndex }, + changedParam.paramId, + rawValue, + ); + } else { + await nativeBridge.setPluginParameter(trackId, fxIndex, isInputFX, changedParam.index, value); + } }; // ---- Plugin Presets handlers ---- @@ -1077,9 +1322,20 @@ export function FXChainPanel({ } }; + const categoryByIdentity = new Map(); + for (const plugin of plugins) { + for (const category of getPluginCategoryTokens(plugin.category)) { + const identity = category.toLowerCase(); + if (!categoryByIdentity.has(identity)) { + categoryByIdentity.set(identity, category); + } + } + } const categories = [ "All", - ...Array.from(new Set(plugins.map((p) => p.category))), + ...Array.from(categoryByIdentity.values()).sort((a, b) => + a.localeCompare(b), + ), ]; const filteredPlugins = plugins.filter((p) => { if (p.isInstrument && chainType !== "track") return false; @@ -1089,7 +1345,11 @@ export function FXChainPanel({ p.manufacturer.toLowerCase().includes(term) || p.category.toLowerCase().includes(term); const matchesCategory = - categoryFilter === "All" || p.category === categoryFilter; + categoryFilter === "All" || + getPluginCategoryTokens(p.category).some( + (category) => + category.toLowerCase() === categoryFilter.toLowerCase(), + ); return matchesSearch && matchesCategory; }); @@ -1106,6 +1366,9 @@ export function FXChainPanel({ className="fx-chain-panel-two-column" onClick={(e) => e.stopPropagation()} onContextMenu={guardModalContextMenu} + onPointerDownCapture={() => activateShortcutContext({ kind: "plugin", sessionId: fxShortcutSessionId })} + onFocusCapture={() => activateShortcutContext({ kind: "plugin", sessionId: fxShortcutSessionId })} + data-shortcut-context={`plugin:${fxShortcutSessionId}`} >

@@ -1319,6 +1582,7 @@ export function FXChainPanel({ }} fallbackName={fallbackInstrumentDisplayName} onClose={() => setExpandedFallbackInstrument(false)} + shortcutSessionId={fxShortcutSessionId} /> )}

@@ -1392,28 +1656,20 @@ export function FXChainPanel({ return (
setSelectedFxIndex(fx.index)} + onFocus={() => setSelectedFxIndex(fx.index)} onDragStart={() => handleDragStart(index)} onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, index)} onClick={() => { if (isS13FX) handleToggleS13FXSliders(fx.index); else if (isBuiltIn) { - setExpandedBuiltInFX( - expandedBuiltInFX === fx.index - ? null - : fx.index, - ); - if (fx.name.includes("Pitch Correct")) { - setExpandedPitchCorrector( - expandedPitchCorrector === fx.index - ? null - : fx.index, - ); - } else { - setExpandedPitchCorrector(null); - } + void handleOpenBuiltInEditor(fx); + setExpandedPitchCorrector(null); } else handleOpenEditor(fx.index); }} > @@ -1469,7 +1725,7 @@ export function FXChainPanel({ isS13FX ? "Click to show sliders" : isBuiltIn - ? "Click to edit built-in plugin" + ? "Click to open editor window" : "Click to open editor" } > @@ -1506,6 +1762,21 @@ export function FXChainPanel({ )} + {isBuiltIn && ( + + )} {/* A/B Comparison Toggle */} {!isS13FX && (() => { @@ -1645,19 +1916,6 @@ export function FXChainPanel({
)} - {/* React built-in editor panel */} - {isBuiltIn && expandedBuiltInFX === fx.index && ( - setExpandedBuiltInFX(null)} - /> - )} - {/* Plugin Parameter Automation List */} {!isS13FX && expandedParamsFx === fx.index && (
@@ -1677,7 +1935,12 @@ export function FXChainPanel({ {pluginParams.map((param) => { const isLearning = midiLearnActive?.fxIndex === fx.index && - midiLearnActive?.paramIndex === param.index; + midiLearnActive?.paramKey === (param.builtIn && param.paramId + ? `builtin:${param.paramId}` + : `plugin:${param.index}`); + const automationParamId = param.builtIn && param.paramId + ? builtInAutomationParamId(chainType === "input", fx.index, param.paramId) + : pluginAutomationParamId(chainType === "input", fx.index, param.index); return (
{param.text} - { + onBeginEdit={() => { if (chainType !== "master") { beginAutomationParamTouch( trackId, - pluginAutomationParamId( - chainType === "input", - fx.index, - param.index, - ), + automationParamId, ); } }} - onPointerUp={() => { + onCommitEdit={() => { if (chainType !== "master") { endAutomationParamTouch( trackId, - pluginAutomationParamId( - chainType === "input", - fx.index, - param.index, - ), + automationParamId, ); } }} - onPointerCancel={() => { - if (chainType !== "master") { - endAutomationParamTouch( - trackId, - pluginAutomationParamId( - chainType === "input", - fx.index, - param.index, - ), - ); - } - }} - onBlur={() => { - if (chainType !== "master") { - endAutomationParamTouch( - trackId, - pluginAutomationParamId( - chainType === "input", - fx.index, - param.index, - ), - ); - } - }} - onChange={(event) => { + onValueChange={(value) => { void handlePluginParamChange( fx.index, - param.index, - Number(event.currentTarget.value), + param, + value, ); }} title={`Set ${param.name}`} /> {/* MIDI Learn button */} - {isLearning ? ( + {chainType !== "master" && (isLearning ? ( - )} + ))} {chainType !== "master" && (
+ {(pluginScanReport || pluginScanError) && ( +
0 || + (pluginScanReport?.skippedCount ?? 0) > 0 + ? "border-amber-800/60 bg-amber-950/30 text-amber-200" + : "border-emerald-800/50 bg-emerald-950/20 text-emerald-200" + }`} + role={pluginScanError ? "alert" : "status"} + > + {pluginScanError || + (!pluginScanReport?.success && pluginScanReport?.error) || + `Cataloged ${pluginScanReport?.pluginCount ?? 0} plug-in classes from ${pluginScanReport?.candidateCount ?? 0} candidates${ + (pluginScanReport?.failedCount ?? 0) > 0 + ? `; ${pluginScanReport?.failedCount} could not be loaded. Open the full Plugin Browser for details.` + : (pluginScanReport?.skippedCount ?? 0) > 0 + ? `; ${pluginScanReport?.skippedCount} were skipped. Open the full Plugin Browser for details or retry.` + : "." + }`} +
+ )} + {/* Plugin List */}
{pluginsLoading ? ( @@ -2066,10 +2332,13 @@ export function FXChainPanel({
) : filteredPlugins.length === 0 ? (
- No plugins found. Click "Scan" to search your system. + {plugins.length > 0 + ? "No plug-ins match the current search or category filter." + : "No plug-ins are cataloged. Click Deep Scan to inspect the configured folders."}
) : ( - filteredPlugins.map((plugin, idx) => { + filteredPlugins.map((plugin) => { + const pluginReference = getPluginReference(plugin); const isScript = plugin.pluginType === "s13fx"; const isBuiltInPlugin = plugin.pluginType === "builtin"; const { Icon, color } = isScript @@ -2079,7 +2348,7 @@ export function FXChainPanel({ : getCategoryIcon(plugin.category); return (
handleAddPlugin(plugin)} disabled={addingPlugin !== null} > - {addingPlugin === plugin.fileOrIdentifier + {addingPlugin === pluginReference ? "Adding..." : "Add"} diff --git a/frontend/src/components/GettingStartedGuide.tsx b/frontend/src/components/GettingStartedGuide.tsx index 95932ba..6830967 100644 --- a/frontend/src/components/GettingStartedGuide.tsx +++ b/frontend/src/components/GettingStartedGuide.tsx @@ -1,7 +1,13 @@ -import { useState, useCallback, useMemo } from "react"; +import { + useState, + useCallback, + useEffect, + useMemo, + useRef, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; import { useShallow } from "zustand/shallow"; import { - X, ChevronLeft, ChevronRight, Sparkles, @@ -15,11 +21,27 @@ import { Download, Wand2, HelpCircle, + X, } from "lucide-react"; -import { getEffectiveActionShortcut } from "../store/actionRegistry"; import { useDAWStore } from "../store/useDAWStore"; import { Button } from "./ui"; +import { + getKeyboardShortcutProfile, + getKeyboardShortcutProfilePresentation, + KEYBOARD_SHORTCUT_PROFILES, +} from "../utils/shortcutProfiles"; +import { + getEffectiveShortcutLabel, + getTimelineWheelHelp, +} from "../utils/inputProfileHelp"; +import { getShortcutPlatform } from "../utils/platform"; +import { getMouseBehaviorProfile } from "../utils/mouseBehaviorProfiles"; import { guardModalContextMenu } from "../utils/modalEventGuards"; +import { + routeModalShortcutEvent, + useModalShortcutScope, +} from "../utils/modalShortcutScope"; +import { activateShortcutContext } from "../utils/shortcutContext"; interface GuideStep { icon: React.ReactNode; @@ -30,10 +52,25 @@ interface GuideStep { } function shortcut(actionId: string, fallback: string): string { - return getEffectiveActionShortcut(actionId) ?? fallback; + return getEffectiveShortcutLabel(actionId, fallback); } function buildGuideSteps(): GuideStep[] { + const state = useDAWStore.getState(); + const shortcutPlatform = getShortcutPlatform(); + const keyboardProfile = getKeyboardShortcutProfile(state.keyboardShortcutProfileId); + const keyboardPresentation = getKeyboardShortcutProfilePresentation( + state.keyboardShortcutProfileId, + shortcutPlatform, + ); + const mouseProfile = getMouseBehaviorProfile( + state.mouseBehaviorProfileId, + shortcutPlatform, + ); + const wheelHelp = getTimelineWheelHelp( + state.mouseBehaviorProfileId, + shortcutPlatform, + ); const commandPaletteShortcut = shortcut("view.commandPalette", "Ctrl+Shift+P"); const audioTrackShortcut = shortcut("insert.audioTrack", "Ctrl+T"); const midiTrackShortcut = shortcut("insert.midiTrack", "Ctrl+Shift+T"); @@ -49,6 +86,9 @@ function buildGuideSteps(): GuideStep[] { const playShortcut = shortcut("transport.play", "Space"); const mixerShortcut = shortcut("view.toggleMixer", "Ctrl+M"); const virtualKeyboardShortcut = shortcut("view.toggleVirtualKeyboard", "Alt+B"); + const availableProfileNames = KEYBOARD_SHORTCUT_PROFILES + .map((profile) => profile.shortName) + .join(", "); return [ { @@ -64,17 +104,28 @@ function buildGuideSteps(): GuideStep[] { ], tip: `Press ${commandPaletteShortcut} at any time to search for actions instead of hunting through menus.`, }, + { + icon: , + title: "Choose Familiar Controls", + description: + "Keyboard shortcuts and mouse behavior are independent profiles, so you can use the key map from one DAW and the scroll or drag conventions from another.", + details: [ + `Current keyboard profile: ${keyboardProfile.name}`, + `Current mouse & scroll profile: ${mouseProfile.name}`, + `Choose from ${availableProfileNames}`, + "Custom action bindings override the selected keyboard profile", + keyboardPresentation.policyLabel, + keyboardPresentation.availabilityLabel, + ], + tip: "Open Keyboard Shortcuts to switch profiles or rebind any global, Timeline, Piano Roll, Pitch Editor, Mixer, browser, or plug-in scoped action.", + }, { icon: , title: "Essential Navigation Gestures & Hotkeys", description: - "Learn these controls first. They cover most of what a new user needs in the first two minutes and match the current app behavior exactly.", + `Learn these controls first. The displayed hotkeys follow ${keyboardProfile.name}, and the gestures follow ${mouseProfile.name}.`, details: [ - "Scroll: native vertical scrolling through the workspace", - "Ctrl+Scroll: zoom the timeline horizontally around the pointer", - "Shift+Scroll: move horizontally through the timeline", - "Alt+Scroll: resize track height", - "Ctrl+Shift+Scroll: zoom track height more aggressively", + ...wheelHelp.items.map((item) => `${item.gesture}: ${item.action}`), `${playShortcut}: Play / Stop`, `${recordShortcut}: Start recording on armed tracks`, `${audioTrackShortcut}: New audio track`, @@ -198,32 +249,47 @@ function buildGuideSteps(): GuideStep[] { "Once you know the core gestures and shortcuts, the fastest next step is to use the built-in references instead of memorizing everything immediately.", details: [ `${helpShortcut}: Help Reference for searchable feature guidance`, - "Keyboard Shortcuts window for the full shortcut list and custom global rebinding", + "Keyboard Shortcuts window for profiles, the full shortcut list, and scoped rebinding", `Preferences (${preferencesShortcut}) for editing, display, mouse, and backup settings`, `Command Palette (${commandPaletteShortcut}) to find actions by name`, ], - tip: "If a shortcut behaves differently than expected, check the Keyboard Shortcuts window first because global bindings may have been customized.", + tip: "If a shortcut behaves differently than expected, check the Keyboard Shortcuts window first because its scoped binding or selected profile may have changed.", }, ]; } const LS_KEY = "openstudio_gettingStartedDismissed"; +const GUIDE_FOCUSABLE_SELECTOR = [ + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[href]", + "[tabindex]:not([tabindex='-1'])", +].join(","); export function GettingStartedGuide() { - const { showGettingStarted, toggleGettingStarted } = useDAWStore( + const { showGettingStarted, toggleGettingStarted, customShortcuts, keyboardShortcutProfileId, mouseBehaviorProfileId } = useDAWStore( useShallow((s) => ({ showGettingStarted: s.showGettingStarted, toggleGettingStarted: s.toggleGettingStarted, + customShortcuts: s.customShortcuts, + keyboardShortcutProfileId: s.keyboardShortcutProfileId, + mouseBehaviorProfileId: s.mouseBehaviorProfileId, })), ); - const customShortcuts = useDAWStore((s) => s.customShortcuts); const [currentStep, setCurrentStep] = useState(0); + const dialogRef = useRef(null); + const returnFocusRef = useRef(null); const [dontShowAgain, setDontShowAgain] = useState( () => localStorage.getItem(LS_KEY) === "true", ); - const steps = useMemo(() => buildGuideSteps(), [customShortcuts]); + const steps = useMemo( + () => buildGuideSteps(), + [customShortcuts, keyboardShortcutProfileId, mouseBehaviorProfileId], + ); const step = steps[currentStep]; const isFirst = currentStep === 0; const isLast = currentStep === steps.length - 1; @@ -231,11 +297,15 @@ export function GettingStartedGuide() { const handleClose = useCallback(() => { if (dontShowAgain) { localStorage.setItem(LS_KEY, "true"); + } else { + localStorage.removeItem(LS_KEY); } setCurrentStep(0); toggleGettingStarted(); }, [dontShowAgain, toggleGettingStarted]); + useModalShortcutScope(showGettingStarted, handleClose); + const handleNext = useCallback(() => { if (isLast) { handleClose(); @@ -248,6 +318,49 @@ export function GettingStartedGuide() { setCurrentStep((s) => Math.max(0, s - 1)); }, []); + useEffect(() => { + if (!showGettingStarted) return; + returnFocusRef.current = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const frame = window.requestAnimationFrame(() => dialogRef.current?.focus()); + return () => { + window.cancelAnimationFrame(frame); + const returnTarget = returnFocusRef.current; + returnFocusRef.current = null; + if (returnTarget?.isConnected) returnTarget.focus({ preventScroll: true }); + }; + }, [showGettingStarted]); + + const handleDialogKeyDown = useCallback((event: ReactKeyboardEvent) => { + const modalRoute = routeModalShortcutEvent(event.nativeEvent); + if (modalRoute.result !== "unmatched" || modalRoute.suppressedHeadlessEscape) { + if (modalRoute.result !== "unmatched") event.preventDefault(); + event.stopPropagation(); + return; + } + if (event.key !== "Tab") return; + const dialog = dialogRef.current; + if (!dialog) return; + const focusable = Array.from( + dialog.querySelectorAll(GUIDE_FOCUSABLE_SELECTOR), + ).filter((element) => element.offsetParent !== null); + if (focusable.length === 0) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }, []); + if (!showGettingStarted) return null; return ( @@ -255,13 +368,30 @@ export function GettingStartedGuide() { className="fixed inset-0 z-[10000] flex items-center justify-center p-4" data-modal-root="true" onContextMenu={guardModalContextMenu} + onPointerDownCapture={() => activateShortcutContext({ kind: "modal" })} + onFocusCapture={() => activateShortcutContext({ kind: "modal" })} > -
+